diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-01-30 04:37:25 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-01-30 04:37:25 +0900 |
| commit | f6154dc0af1a0d65819e87240f4385f9573095cb (patch) | |
| tree | 699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/components | |
| parent | Add Event activity-type support (#5785) (diff) | |
| download | sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2 sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip | |
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/client/components')
102 files changed, 14246 insertions, 0 deletions
diff --git a/src/client/components/acct.vue b/src/client/components/acct.vue new file mode 100644 index 0000000000..250e8b2371 --- /dev/null +++ b/src/client/components/acct.vue @@ -0,0 +1,29 @@ +<template> +<span class="mk-acct" v-once> + <span class="name">@{{ user.username }}</span> + <span class="host" v-if="user.host || detail || $store.state.settings.showFullAcct">@{{ user.host || host }}</span> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { toUnicode } from 'punycode'; +import { host } from '../config'; + +export default Vue.extend({ + props: ['user', 'detail'], + data() { + return { + host: toUnicode(host), + }; + } +}); +</script> + +<style lang="scss" scoped> +.mk-acct { + > .host { + opacity: 0.5; + } +} +</style> diff --git a/src/client/components/autocomplete.vue b/src/client/components/autocomplete.vue new file mode 100644 index 0000000000..232b25dd61 --- /dev/null +++ b/src/client/components/autocomplete.vue @@ -0,0 +1,443 @@ +<template> +<div class="mk-autocomplete" @contextmenu.prevent="() => {}"> + <ol class="users" ref="suggests" v-if="users.length > 0"> + <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1"> + <img class="avatar" :src="user.avatarUrl" alt=""/> + <span class="name"> + <mk-user-name :user="user" :key="user.id"/> + </span> + <span class="username">@{{ user | acct }}</span> + </li> + </ol> + <ol class="hashtags" ref="suggests" v-if="hashtags.length > 0"> + <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1"> + <span class="name">{{ hashtag }}</span> + </li> + </ol> + <ol class="emojis" ref="suggests" v-if="emojis.length > 0"> + <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> + <span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> + <span class="emoji" v-else-if="!useOsDefaultEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span> + <span class="emoji" v-else>{{ emoji.emoji }}</span> + <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> + <span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { emojilist } from '../../misc/emojilist'; +import contains from '../scripts/contains'; +import { twemojiSvgBase } from '../../misc/twemoji-base'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; + +type EmojiDef = { + emoji: string; + name: string; + aliasOf?: string; + url?: string; + isCustomEmoji?: boolean; +}; + +const lib = emojilist.filter(x => x.category !== 'flags'); + +const char2file = (char: string) => { + let codes = Array.from(char).map(x => x.codePointAt(0).toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); + return codes.join('-'); +}; + +const emjdb: EmojiDef[] = lib.map(x => ({ + emoji: x.char, + name: x.name, + aliasOf: null, + url: `${twemojiSvgBase}/${char2file(x.char)}.svg` +})); + +for (const x of lib) { + if (x.keywords) { + for (const k of x.keywords) { + emjdb.push({ + emoji: x.char, + name: k, + aliasOf: x.name, + url: `${twemojiSvgBase}/${char2file(x.char)}.svg` + }); + } + } +} + +emjdb.sort((a, b) => a.name.length - b.name.length); + +export default Vue.extend({ + props: ['type', 'q', 'textarea', 'complete', 'close', 'x', 'y'], + + data() { + return { + getStaticImageUrl, + fetching: true, + users: [], + hashtags: [], + emojis: [], + select: -1, + emojilist, + emojiDb: [] as EmojiDef[] + } + }, + + computed: { + items(): HTMLCollection { + return (this.$refs.suggests as Element).children; + }, + + useOsDefaultEmojis(): boolean { + return this.$store.state.device.useOsDefaultEmojis; + } + }, + + updated() { + //#region 位置調整 + if (this.x + this.$el.offsetWidth > window.innerWidth) { + this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px'; + } else { + this.$el.style.left = this.x + 'px'; + } + + if (this.y + this.$el.offsetHeight > window.innerHeight) { + this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px'; + this.$el.style.marginTop = '0'; + } else { + this.$el.style.top = this.y + 'px'; + this.$el.style.marginTop = 'calc(1em + 8px)'; + } + //#endregion + }, + + mounted() { + //#region Construct Emoji DB + const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; + const emojiDefinitions: EmojiDef[] = []; + + for (const x of customEmojis) { + emojiDefinitions.push({ + name: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true + }); + + if (x.aliases) { + for (const alias of x.aliases) { + emojiDefinitions.push({ + name: alias, + aliasOf: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true + }); + } + } + } + + emojiDefinitions.sort((a, b) => a.name.length - b.name.length); + + this.emojiDb = emojiDefinitions.concat(emjdb); + //#endregion + + this.textarea.addEventListener('keydown', this.onKeydown); + + for (const el of Array.from(document.querySelectorAll('*'))) { + el.addEventListener('mousedown', this.onMousedown); + } + + this.$nextTick(() => { + this.exec(); + + this.$watch('q', () => { + this.$nextTick(() => { + this.exec(); + }); + }); + }); + }, + + beforeDestroy() { + this.textarea.removeEventListener('keydown', this.onKeydown); + + for (const el of Array.from(document.querySelectorAll('*'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + + methods: { + exec() { + this.select = -1; + if (this.$refs.suggests) { + for (const el of Array.from(this.items)) { + el.removeAttribute('data-selected'); + } + } + + if (this.type == 'user') { + const cacheKey = `autocomplete:user:${this.q}`; + const cache = sessionStorage.getItem(cacheKey); + if (cache) { + const users = JSON.parse(cache); + this.users = users; + this.fetching = false; + } else { + this.$root.api('users/search', { + query: this.q, + limit: 10, + detail: false + }).then(users => { + this.users = users; + this.fetching = false; + + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(users)); + }); + } + } else if (this.type == 'hashtag') { + if (this.q == null || this.q == '') { + this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); + this.fetching = false; + } else { + const cacheKey = `autocomplete:hashtag:${this.q}`; + const cache = sessionStorage.getItem(cacheKey); + if (cache) { + const hashtags = JSON.parse(cache); + this.hashtags = hashtags; + this.fetching = false; + } else { + this.$root.api('hashtags/search', { + query: this.q, + limit: 30 + }).then(hashtags => { + this.hashtags = hashtags; + this.fetching = false; + + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(hashtags)); + }); + } + } + } else if (this.type == 'emoji') { + if (this.q == null || this.q == '') { + this.emojis = this.emojiDb.filter(x => x.isCustomEmoji && !x.aliasOf).sort((a, b) => { + var textA = a.name.toUpperCase(); + var textB = b.name.toUpperCase(); + return (textA < textB) ? -1 : (textA > textB) ? 1 : 0; + }); + return; + } + + const matched = []; + const max = 30; + + this.emojiDb.some(x => { + if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + if (matched.length < max) { + this.emojiDb.some(x => { + if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + } + if (matched.length < max) { + this.emojiDb.some(x => { + if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + } + + this.emojis = matched; + } + }, + + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + + onKeydown(e) { + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (e.which) { + case 10: // [ENTER] + case 13: // [ENTER] + if (this.select !== -1) { + cancel(); + (this.items[this.select] as any).click(); + } else { + this.close(); + } + break; + + case 27: // [ESC] + cancel(); + this.close(); + break; + + case 38: // [↑] + if (this.select !== -1) { + cancel(); + this.selectPrev(); + } else { + this.close(); + } + break; + + case 9: // [TAB] + case 40: // [↓] + cancel(); + this.selectNext(); + break; + + default: + e.stopPropagation(); + this.textarea.focus(); + } + }, + + selectNext() { + if (++this.select >= this.items.length) this.select = 0; + this.applySelect(); + }, + + selectPrev() { + if (--this.select < 0) this.select = this.items.length - 1; + this.applySelect(); + }, + + applySelect() { + for (const el of Array.from(this.items)) { + el.removeAttribute('data-selected'); + } + + this.items[this.select].setAttribute('data-selected', 'true'); + (this.items[this.select] as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-autocomplete { + position: fixed; + z-index: 65535; + max-width: 100%; + margin-top: calc(1em + 8px); + overflow: hidden; + background: var(--panel); + border: solid 1px rgba(#000, 0.1); + border-radius: 4px; + transition: top 0.1s ease, left 0.1s ease; + + > ol { + display: block; + margin: 0; + padding: 4px 0; + max-height: 190px; + max-width: 500px; + overflow: auto; + list-style: none; + + > li { + display: flex; + align-items: center; + padding: 4px 12px; + white-space: nowrap; + overflow: hidden; + font-size: 0.9em; + cursor: default; + + &, * { + user-select: none; + } + + * { + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + background: var(--yrnqrguo); + } + + &[data-selected='true'] { + background: var(--accent); + + &, * { + color: #fff !important; + } + } + + &:active { + background: var(--accentDarken); + + &, * { + color: #fff !important; + } + } + } + } + + > .users > li { + + .avatar { + min-width: 28px; + min-height: 28px; + max-width: 28px; + max-height: 28px; + margin: 0 8px 0 0; + border-radius: 100%; + } + + .name { + margin: 0 8px 0 0; + color: var(--autocompleteItemText); + } + + .username { + color: var(--autocompleteItemTextSub); + } + } + + > .hashtags > li { + + .name { + color: var(--autocompleteItemText); + } + } + + > .emojis > li { + + .emoji { + display: inline-block; + margin: 0 4px 0 0; + width: 24px; + + > img { + width: 24px; + vertical-align: bottom; + } + } + + .name { + color: var(--autocompleteItemText); + } + + .alias { + margin: 0 0 0 8px; + color: var(--autocompleteItemTextSub); + } + } +} +</style> diff --git a/src/client/components/avatar.vue b/src/client/components/avatar.vue new file mode 100644 index 0000000000..12cbb82478 --- /dev/null +++ b/src/client/components/avatar.vue @@ -0,0 +1,116 @@ +<template> +<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-if="disableLink && !disablePreview" v-user-preview="user.id" @click="onClick"> + <span class="inner" :style="icon"></span> +</span> +<span class="mk-avatar" :class="{ cat }" :title="user | acct" v-else-if="disableLink && disablePreview" @click="onClick"> + <span class="inner" :style="icon"></span> +</span> +<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && !disablePreview" v-user-preview="user.id"> + <span class="inner" :style="icon"></span> +</router-link> +<router-link class="mk-avatar" :class="{ cat }" :to="user | userPage" :title="user | acct" :target="target" v-else-if="!disableLink && disablePreview"> + <span class="inner" :style="icon"></span> +</router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; + +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + }, + target: { + required: false, + default: null + }, + disableLink: { + required: false, + default: false + }, + disablePreview: { + required: false, + default: false + } + }, + computed: { + cat(): boolean { + return this.user.isCat; + }, + url(): string { + return this.$store.state.device.disableShowingAnimatedImages + ? getStaticImageUrl(this.user.avatarUrl) + : this.user.avatarUrl; + }, + icon(): any { + return { + backgroundColor: this.user.avatarColor, + backgroundImage: `url(${this.url})`, + }; + } + }, + watch: { + 'user.avatarColor'() { + this.$el.style.color = this.user.avatarColor; + } + }, + mounted() { + if (this.user.avatarColor) { + this.$el.style.color = this.user.avatarColor; + } + }, + methods: { + onClick(e) { + this.$emit('click', e); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-avatar { + position: relative; + display: inline-block; + vertical-align: bottom; + flex-shrink: 0; + border-radius: 100%; + line-height: 16px; + + &.cat { + &:before, &:after { + background: #df548f; + border: solid 4px currentColor; + box-sizing: border-box; + content: ''; + display: inline-block; + height: 50%; + width: 50%; + } + + &:before { + border-radius: 0 75% 75%; + transform: rotate(37.5deg) skew(30deg); + } + + &:after { + border-radius: 75% 0 75% 75%; + transform: rotate(-37.5deg) skew(-30deg); + } + } + + .inner { + background-position: center center; + background-size: cover; + bottom: 0; + left: 0; + position: absolute; + right: 0; + top: 0; + border-radius: 100%; + z-index: 1; + } +} +</style> diff --git a/src/client/components/avatars.vue b/src/client/components/avatars.vue new file mode 100644 index 0000000000..0dc1ece3bf --- /dev/null +++ b/src/client/components/avatars.vue @@ -0,0 +1,27 @@ +<template> +<div> + <mk-avatar v-for="user in us" :user="user" :key="user.id" style="width:32px;height:32px;"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + userIds: { + required: true + }, + }, + data() { + return { + us: [] + }; + }, + async created() { + this.us = await this.$root.api('users/show', { + userIds: this.userIds + }); + } +}); +</script> diff --git a/src/client/components/code-core.vue b/src/client/components/code-core.vue new file mode 100644 index 0000000000..a9253528d9 --- /dev/null +++ b/src/client/components/code-core.vue @@ -0,0 +1,34 @@ +<template> +<x-prism :inline="inline" :language="prismLang">{{ code }}</x-prism> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import 'prismjs'; +import 'prismjs/themes/prism-okaidia.css'; +import XPrism from 'vue-prism-component'; +export default Vue.extend({ + components: { + XPrism + }, + props: { + code: { + type: String, + required: true + }, + lang: { + type: String, + required: false + }, + inline: { + type: Boolean, + required: false + } + }, + computed: { + prismLang() { + return Prism.languages[this.lang] ? this.lang : 'js'; + } + } +}); +</script> diff --git a/src/client/components/code.vue b/src/client/components/code.vue new file mode 100644 index 0000000000..94cad57be4 --- /dev/null +++ b/src/client/components/code.vue @@ -0,0 +1,26 @@ +<template> +<x-code :code="code" :lang="lang" :inline="inline"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + components: { + XCode: () => import('./code-core.vue').then(m => m.default) + }, + props: { + code: { + type: String, + required: true + }, + lang: { + type: String, + required: false + }, + inline: { + type: Boolean, + required: false + } + } +}); +</script> diff --git a/src/client/components/cw-button.vue b/src/client/components/cw-button.vue new file mode 100644 index 0000000000..4516e5210c --- /dev/null +++ b/src/client/components/cw-button.vue @@ -0,0 +1,73 @@ +<template> +<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh _button" @click="toggle"> + <b>{{ value ? this.$t('_cw.hide') : this.$t('_cw.show') }}</b> + <span v-if="!value">{{ this.label }}</span> +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { length } from 'stringz'; +import { concat } from '../../prelude/array'; + +export default Vue.extend({ + i18n, + + props: { + value: { + type: Boolean, + required: true + }, + note: { + type: Object, + required: true + } + }, + + computed: { + label(): string { + return concat([ + this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [], + this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [], + this.note.poll != null ? [this.$t('_cw.poll')] : [] + ] as string[][]).join(' / '); + } + }, + + methods: { + length, + + toggle() { + this.$emit('input', !this.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.nrvgflfuaxwgkxoynpnumyookecqrrvh { + display: inline-block; + padding: 4px 8px; + font-size: 0.7em; + color: var(--cwFg); + background: var(--cwBg); + border-radius: 2px; + + &:hover { + background: var(--cwHoverBg); + } + + > span { + margin-left: 4px; + + &:before { + content: '('; + } + + &:after { + content: ')'; + } + } +} +</style> diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue new file mode 100644 index 0000000000..00c3cd6643 --- /dev/null +++ b/src/client/components/date-separated-list.vue @@ -0,0 +1,94 @@ +<template> +<sequential-entrance class="sqadhkmv" ref="list" :direction="direction"> + <template v-for="(item, i) in items"> + <slot :item="item" :i="i"></slot> + <div class="separator" :key="item.id + '_date'" :data-index="i" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()"> + <p class="date"> + <span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span> + <span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span> + </p> + </div> + </template> +</sequential-entrance> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + props: { + items: { + type: Array, + required: true, + }, + direction: { + type: String, + required: false + } + }, + + data() { + return { + faAngleUp, faAngleDown + }; + }, + + methods: { + getDateText(time: string) { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return this.$t('monthAndDay', { + month: month.toString(), + day: date.toString() + }); + }, + + focus() { + this.$refs.list.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.sqadhkmv { + > .separator { + text-align: center; + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + border-radius: 64px; + background: var(--dateLabelBg); + color: var(--dateLabelFg); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } +} +</style> diff --git a/src/client/components/dialog.vue b/src/client/components/dialog.vue new file mode 100644 index 0000000000..5311611575 --- /dev/null +++ b/src/client/components/dialog.vue @@ -0,0 +1,320 @@ +<template> +<div class="mk-dialog" :class="{ iconOnly }"> + <transition name="bg-fade" appear> + <div class="bg" ref="bg" @click="onBgClick" v-if="show"></div> + </transition> + <transition name="dialog" appear @after-leave="() => { destroyDom(); }"> + <div class="main" ref="main" v-if="show"> + <template v-if="type == 'signin'"> + <mk-signin/> + </template> + <template v-else> + <div class="icon" v-if="icon"> + <fa :icon="icon"/> + </div> + <div class="icon" v-else-if="!input && !select && !user" :class="type"> + <fa :icon="faCheck" v-if="type === 'success'"/> + <fa :icon="faTimesCircle" v-if="type === 'error'"/> + <fa :icon="faExclamationTriangle" v-if="type === 'warning'"/> + <fa :icon="faInfoCircle" v-if="type === 'info'"/> + <fa :icon="faQuestionCircle" v-if="type === 'question'"/> + <fa :icon="faSpinner" pulse v-if="type === 'waiting'"/> + </div> + <header v-if="title" v-html="title"></header> + <header v-if="title == null && user">{{ $t('enterUsername') }}</header> + <div class="body" v-if="text" v-html="text"></div> + <mk-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></mk-input> + <mk-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><template #prefix>@</template></mk-input> + <mk-select v-if="select" v-model="selectedValue" autofocus> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </template> + <template v-else> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> + </template> + </mk-select> + <div class="buttons" v-if="!iconOnly && (showOkButton || showCancelButton) && !actions"> + <mk-button inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select && !user" :disabled="!canOk">{{ (showCancelButton || input || select || user) ? $t('ok') : $t('gotIt') }}</mk-button> + <mk-button inline @click="cancel" v-if="showCancelButton || input || select || user">{{ $t('cancel') }}</mk-button> + </div> + <div class="buttons" v-if="actions"> + <mk-button v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</mk-button> + </div> + </template> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner, faInfoCircle, faExclamationTriangle, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons'; +import MkButton from './ui/button.vue'; +import MkInput from './ui/input.vue'; +import MkSelect from './ui/select.vue'; +import parseAcct from '../../misc/acct/parse'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkInput, + MkSelect, + }, + + props: { + type: { + type: String, + required: false, + default: 'info' + }, + title: { + type: String, + required: false + }, + text: { + type: String, + required: false + }, + input: { + required: false + }, + select: { + required: false + }, + user: { + required: false + }, + icon: { + required: false + }, + actions: { + required: false + }, + showOkButton: { + type: Boolean, + default: true + }, + showCancelButton: { + type: Boolean, + default: false + }, + cancelableByBgClick: { + type: Boolean, + default: true + }, + iconOnly: { + type: Boolean, + default: false + }, + autoClose: { + type: Boolean, + default: false + } + }, + + data() { + return { + show: true, + inputValue: this.input && this.input.default ? this.input.default : null, + userInputValue: null, + selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null, + canOk: true, + faTimesCircle, faQuestionCircle, faSpinner, faInfoCircle, faExclamationTriangle, faCheck + }; + }, + + watch: { + userInputValue() { + if (this.user) { + this.$root.api('users/show', parseAcct(this.userInputValue)).then(u => { + this.canOk = u != null; + }).catch(() => { + this.canOk = false; + }); + } + } + }, + + mounted() { + if (this.user) this.canOk = false; + + if (this.autoClose) { + setTimeout(() => { + this.close(); + }, 1000); + } + + document.addEventListener('keydown', this.onKeydown); + }, + + beforeDestroy() { + document.removeEventListener('keydown', this.onKeydown); + }, + + methods: { + async ok() { + if (!this.canOk) return; + if (!this.showOkButton) return; + + if (this.user) { + const user = await this.$root.api('users/show', parseAcct(this.userInputValue)); + if (user) { + this.$emit('ok', user); + this.close(); + } + } else { + const result = + this.input ? this.inputValue : + this.select ? this.selectedValue : + true; + this.$emit('ok', result); + this.close(); + } + }, + + cancel() { + this.$emit('cancel'); + this.close(); + }, + + close() { + if (!this.show) return; + this.show = false; + this.$el.style.pointerEvents = 'none'; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.main as any).style.pointerEvents = 'none'; + }, + + onBgClick() { + if (this.cancelableByBgClick) { + this.cancel(); + } + }, + + onKeydown(e) { + if (e.which === 27) { // ESC + this.cancel(); + } + }, + + onInputKeydown(e) { + if (e.which === 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.dialog-enter-active, .dialog-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.dialog-enter, .dialog-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.bg-fade-enter-active, .bg-fade-leave-active { + transition: opacity 0.3s !important; +} +.bg-fade-enter, .bg-fade-leave-to { + opacity: 0; +} + +.mk-dialog { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + z-index: 30000; + top: 0; + left: 0; + width: 100%; + height: 100%; + + &.iconOnly > .main { + min-width: 0; + width: initial; + } + + > .bg { + display: block; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.7); + } + + > .main { + display: block; + position: fixed; + margin: auto; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + width: calc(100% - 32px); + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .icon { + font-size: 32px; + + &.success { + color: var(--accent); + } + + &.error { + color: #ec4137; + } + + &.warning { + color: #ecb637; + } + + > * { + display: block; + margin: 0 auto; + } + + & + header { + margin-top: 16px; + } + } + + > header { + margin: 0 0 8px 0; + font-weight: bold; + font-size: 20px; + + & + .body { + margin-top: 8px; + } + } + + > .body { + margin: 16px 0 0 0; + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } + } +} +</style> diff --git a/src/client/components/drive-file-thumbnail.vue b/src/client/components/drive-file-thumbnail.vue new file mode 100644 index 0000000000..37a884dc3d --- /dev/null +++ b/src/client/components/drive-file-thumbnail.vue @@ -0,0 +1,188 @@ +<template> +<div class="zdjebgpv" :class="{ detail }" ref="thumbnail" :style="`background-color: ${ background }`"> + <img + :src="file.url" + :alt="file.name" + :title="file.name" + @load="onThumbnailLoaded" + v-if="detail && is === 'image'"/> + <video + :src="file.url" + ref="volumectrl" + preload="metadata" + controls + v-else-if="detail && is === 'video'"/> + <img :src="file.thumbnailUrl" alt="" @load="onThumbnailLoaded" :style="`object-fit: ${ fit }`" v-else-if="isThumbnailAvailable"/> + <fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/> + <fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/> + + <audio + :src="file.url" + ref="volumectrl" + preload="metadata" + controls + v-else-if="detail && is === 'audio'"/> + <fa :icon="faMusic" class="icon" v-else-if="is === 'audio' || is === 'midi'"/> + + <fa :icon="faFileCsv" class="icon" v-else-if="is === 'csv'"/> + <fa :icon="faFilePdf" class="icon" v-else-if="is === 'pdf'"/> + <fa :icon="faFileAlt" class="icon" v-else-if="is === 'textfile'"/> + <fa :icon="faFileArchive" class="icon" v-else-if="is === 'archive'"/> + <fa :icon="faFile" class="icon" v-else/> + + <fa :icon="faFilm" class="icon-sub" v-if="!detail && isThumbnailAvailable && is === 'video'"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { + faFile, + faFileAlt, + faFileImage, + faMusic, + faFileVideo, + faFileCsv, + faFilePdf, + faFileArchive, + faFilm + } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + file: { + type: Object, + required: true + }, + fit: { + type: String, + required: false, + default: 'cover' + }, + detail: { + type: Boolean, + required: false, + default: false + } + }, + data() { + return { + isContextmenuShowing: false, + isDragging: false, + + faFile, + faFileAlt, + faFileImage, + faMusic, + faFileVideo, + faFileCsv, + faFilePdf, + faFileArchive, + faFilm + }; + }, + computed: { + is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' { + if (this.file.type.startsWith('image/')) return 'image'; + if (this.file.type.startsWith('video/')) return 'video'; + if (this.file.type === 'audio/midi') return 'midi'; + if (this.file.type.startsWith('audio/')) return 'audio'; + if (this.file.type.endsWith('/csv')) return 'csv'; + if (this.file.type.endsWith('/pdf')) return 'pdf'; + if (this.file.type.startsWith('text/')) return 'textfile'; + if ([ + "application/zip", + "application/x-cpio", + "application/x-bzip", + "application/x-bzip2", + "application/java-archive", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + "application/x-7z-compressed" + ].some(e => e === this.file.type)) return 'archive'; + return 'unknown'; + }, + isThumbnailAvailable(): boolean { + return this.file.thumbnailUrl + ? (this.is === 'image' || this.is === 'video') + : false; + }, + background(): string { + return this.file.properties.avgColor || 'transparent'; + } + }, + mounted() { + const audioTag = this.$refs.volumectrl as HTMLAudioElement; + if (audioTag) audioTag.volume = this.$store.state.device.mediaVolume; + }, + methods: { + onThumbnailLoaded() { + if (this.file.properties.avgColor) { + this.$refs.thumbnail.style.backgroundColor = 'transparent'; + } + }, + volumechange() { + const audioTag = this.$refs.volumectrl as HTMLAudioElement; + this.$store.commit('device/set', { key: 'mediaVolume', value: audioTag.volume }); + } + } +}); +</script> + +<style lang="scss" scoped> +.zdjebgpv { + display: flex; + + > img, + > .icon { + pointer-events: none; + } + + > .icon-sub { + position: absolute; + width: 30%; + height: auto; + margin: 0; + right: 4%; + bottom: 4%; + } + + > * { + margin: auto; + } + + &:not(.detail) { + > img { + height: 100%; + width: 100%; + object-fit: cover; + } + + > .icon { + height: 65%; + width: 65%; + } + + > video, + > audio { + width: 100%; + } + } + + &.detail { + > .icon { + height: 100px; + width: 100px; + margin: 16px; + } + + > *:not(.icon) { + max-height: 300px; + max-width: 100%; + height: 100%; + object-fit: contain; + } + } +} +</style> diff --git a/src/client/components/drive-window.vue b/src/client/components/drive-window.vue new file mode 100644 index 0000000000..64c4cee0c1 --- /dev/null +++ b/src/client/components/drive-window.vue @@ -0,0 +1,53 @@ +<template> +<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected.length === 0" @ok="ok()"> + <template #header>{{ multiple ? $t('selectFiles') : $t('selectFile') }}<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length | number }})</span></template> + <div> + <x-drive :multiple="multiple" @change-selection="onChangeSelection" :select-mode="true"/> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import XDrive from './drive.vue'; +import XWindow from './window.vue'; + +export default Vue.extend({ + i18n, + + components: { + XDrive, + XWindow, + }, + + props: { + type: { + type: String, + required: false, + default: undefined + }, + multiple: { + type: Boolean, + default: false + } + }, + + data() { + return { + selected: [] + }; + }, + + methods: { + ok() { + this.$emit('selected', this.selected); + this.$refs.window.close(); + }, + + onChangeSelection(files) { + this.selected = files; + } + } +}); +</script> diff --git a/src/client/components/drive.file.vue b/src/client/components/drive.file.vue new file mode 100644 index 0000000000..22fc8c6fb7 --- /dev/null +++ b/src/client/components/drive.file.vue @@ -0,0 +1,368 @@ +<template> +<div class="ncvczrfv" + :data-is-selected="isSelected" + @click="onClick" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + :title="title" +> + <div class="label" v-if="$store.state.i.avatarId == file.id"> + <img src="/assets/label.svg"/> + <p>{{ $t('avatar') }}</p> + </div> + <div class="label" v-if="$store.state.i.bannerId == file.id"> + <img src="/assets/label.svg"/> + <p>{{ $t('banner') }}</p> + </div> + <div class="label red" v-if="file.isSensitive"> + <img src="/assets/label-red.svg"/> + <p>{{ $t('nsfw') }}</p> + </div> + + <x-file-thumbnail class="thumbnail" :file="file" fit="contain"/> + + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import copyToClipboard from '../scripts/copy-to-clipboard'; +//import updateAvatar from '../api/update-avatar'; +//import updateBanner from '../api/update-banner'; +import XFileThumbnail from './drive-file-thumbnail.vue'; +import { faDownload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n, + + props: { + file: { + type: Object, + required: true, + }, + selectMode: { + type: Boolean, + required: false, + default: false, + } + }, + + components: { + XFileThumbnail + }, + + data() { + return { + isDragging: false + }; + }, + + computed: { + browser(): any { + return this.$parent; + }, + isSelected(): boolean { + return this.browser.selectedFiles.some(f => f.id == this.file.id); + }, + title(): string { + return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.size)}`; + } + }, + + methods: { + onClick(ev) { + if (this.selectMode) { + this.browser.chooseFile(this.file); + } else { + this.$root.menu({ + items: [{ + type: 'item', + text: this.$t('rename'), + icon: faICursor, + action: this.rename + }, { + type: 'item', + text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'), + icon: this.file.isSensitive ? faEye : faEyeSlash, + action: this.toggleSensitive + }, null, { + type: 'item', + text: this.$t('copyUrl'), + icon: faLink, + action: this.copyUrl + }, { + type: 'a', + href: this.file.url, + target: '_blank', + text: this.$t('download'), + icon: faDownload, + download: this.file.name + }, null, { + type: 'item', + text: this.$t('delete'), + icon: faTrashAlt, + action: this.deleteFile + }, null, { + type: 'nest', + text: this.$t('contextmenu.else-files'), + menu: [{ + type: 'item', + text: this.$t('contextmenu.set-as-avatar'), + action: this.setAsAvatar + }, { + type: 'item', + text: this.$t('contextmenu.set-as-banner'), + action: this.setAsBanner + }] + }], + source: ev.currentTarget || ev.target, + }); + } + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + onThumbnailLoaded() { + if (this.file.properties.avgColor) { + anime({ + targets: this.$refs.thumbnail, + backgroundColor: 'transparent', // TODO fade + duration: 100, + easing: 'linear' + }); + } + }, + + rename() { + this.$root.dialog({ + title: this.$t('contextmenu.rename-file'), + input: { + placeholder: this.$t('contextmenu.input-new-file-name'), + default: this.file.name, + allowEmpty: false + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + this.$root.api('drive/files/update', { + fileId: this.file.id, + name: name + }); + }); + }, + + toggleSensitive() { + this.$root.api('drive/files/update', { + fileId: this.file.id, + isSensitive: !this.file.isSensitive + }); + }, + + copyUrl() { + copyToClipboard(this.file.url); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, + + setAsAvatar() { + updateAvatar(this.$root)(this.file); + }, + + setAsBanner() { + updateBanner(this.$root)(this.file); + }, + + addApp() { + alert('not implemented yet'); + }, + + async deleteFile() { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('driveFileDeleteConfirm', { name: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + this.$root.api('drive/files/delete', { + fileId: this.file.id + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.ncvczrfv { + position: relative; + padding: 8px 0 0 0; + min-height: 180px; + border-radius: 4px; + + &, * { + cursor: pointer; + } + + &:hover { + background: rgba(#000, 0.05); + + > .label { + &:before, + &:after { + background: #0b65a5; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + } + } + + &:active { + background: rgba(#000, 0.1); + + > .label { + &:before, + &:after { + background: #0b588c; + } + + &.red { + &:before, + &:after { + background: #ce2212; + } + } + } + } + + &[data-is-selected] { + background: var(--accent); + + &:hover { + background: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + } + + > .label { + &:before, + &:after { + display: none; + } + } + + > .name { + color: #fff; + } + + > .thumbnail { + color: #fff; + } + } + + > .label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + + &:before, + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1; + background: #0c7ac9; + } + + &:before { + top: 0; + left: 57px; + width: 28px; + height: 8px; + } + + &:after { + top: 57px; + left: 0; + width: 8px; + height: 28px; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + + > img { + position: absolute; + z-index: 2; + top: 0; + left: 0; + } + + > p { + position: absolute; + z-index: 3; + top: 19px; + left: -28px; + width: 120px; + margin: 0; + text-align: center; + line-height: 28px; + color: #fff; + transform: rotate(-45deg); + } + } + + > .thumbnail { + width: 128px; + height: 128px; + margin: auto; + color: var(--driveFileIcon); + } + + > .name { + display: block; + margin: 4px 0 0 0; + font-size: 0.8em; + text-align: center; + word-break: break-all; + color: var(--fg); + overflow: hidden; + + > .ext { + opacity: 0.5; + } + } +} +</style> diff --git a/src/client/components/drive.folder.vue b/src/client/components/drive.folder.vue new file mode 100644 index 0000000000..39a9588772 --- /dev/null +++ b/src/client/components/drive.folder.vue @@ -0,0 +1,281 @@ +<template> +<div class="rghtznwe" + :data-draghover="draghover" + @click="onClick" + @mouseover="onMouseover" + @mouseout="onMouseout" + @dragover.prevent.stop="onDragover" + @dragenter.prevent="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + :title="title" +> + <p class="name"> + <template v-if="hover"><fa :icon="faFolderOpen" fixed-width/></template> + <template v-if="!hover"><fa :icon="faFolder" fixed-width/></template> + {{ folder.name }} + </p> + <p class="upload" v-if="$store.state.settings.uploadFolder == folder.id"> + {{ $t('upload-folder') }} + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faFolder, faFolderOpen } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + props: { + folder: { + type: Object, + required: true, + } + }, + + data() { + return { + hover: false, + draghover: false, + isDragging: false, + faFolder, faFolderOpen + }; + }, + + computed: { + browser(): any { + return this.$parent; + }, + title(): string { + return this.folder.name; + } + }, + methods: { + onClick() { + this.browser.move(this.folder); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false + }, + + onDragover(e) { + // 自分自身がドラッグされている場合 + if (this.isDragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDragenter() { + if (!this.isDragging) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.browser.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + this.$root.api('drive/files/update', { + fileId: file.id, + folderId: this.folder.id + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.id == this.folder.id) return; + + this.browser.removeFolder(folder.id); + this.$root.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder.id + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + this.$root.dialog({ + title: this.$t('unable-to-process'), + text: this.$t('circular-reference-detected') + }); + break; + default: + this.$root.dialog({ + type: 'error', + text: this.$t('unhandled-error') + }); + } + }); + } + //#endregion + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend() { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + go() { + this.browser.move(this.folder.id); + }, + + newWindow() { + this.browser.newWindow(this.folder); + }, + + rename() { + this.$root.dialog({ + title: this.$t('contextmenu.rename-folder'), + input: { + placeholder: this.$t('contextmenu.input-new-folder-name'), + default: this.folder.name + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + this.$root.api('drive/folders/update', { + folderId: this.folder.id, + name: name + }); + }); + }, + + deleteFolder() { + this.$root.api('drive/folders/delete', { + folderId: this.folder.id + }).then(() => { + if (this.$store.state.settings.uploadFolder === this.folder.id) { + this.$store.dispatch('settings/set', { + key: 'uploadFolder', + value: null + }); + } + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + this.$root.dialog({ + type: 'error', + title: this.$t('unable-to-delete'), + text: this.$t('has-child-files-or-folders') + }); + break; + default: + this.$root.dialog({ + type: 'error', + text: this.$t('unable-to-delete') + }); + } + }); + }, + + setAsUploadFolder() { + this.$store.dispatch('settings/set', { + key: 'uploadFolder', + value: this.folder.id + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.rghtznwe { + position: relative; + padding: 8px; + height: 64px; + background: var(--driveFolderBg); + border-radius: 4px; + + &, * { + cursor: pointer; + } + + * { + pointer-events: none; + } + + &[data-draghover] { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + border: 2px dashed var(--focus); + border-radius: 4px; + } + } + + > .name { + margin: 0; + font-size: 0.9em; + color: var(--desktopDriveFolderFg); + + > [data-icon] { + margin-right: 4px; + margin-left: 2px; + text-align: left; + } + } + + > .upload { + margin: 4px 4px; + font-size: 0.8em; + text-align: right; + color: var(--desktopDriveFolderFg); + } +} +</style> diff --git a/src/client/components/drive.nav-folder.vue b/src/client/components/drive.nav-folder.vue new file mode 100644 index 0000000000..0689faecd2 --- /dev/null +++ b/src/client/components/drive.nav-folder.vue @@ -0,0 +1,139 @@ +<template> +<div class="drylbebk" + :data-draghover="draghover" + @click="onClick" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <i v-if="folder == null"><fa :icon="faCloud"/></i> + <span>{{ folder == null ? $t('drive') : folder.name }}</span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCloud } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + + props: { + folder: { + type: Object, + required: false, + } + }, + + data() { + return { + hover: false, + draghover: false, + faCloud + }; + }, + + computed: { + browser(): any { + return this.$parent; + } + }, + + methods: { + onClick() { + this.browser.move(this.folder); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false; + }, + + onDragover(e) { + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (this.folder == null && this.browser.folder == null) { + e.dataTransfer.dropEffect = 'none'; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter() { + if (this.folder || this.browser.folder) this.draghover = true; + }, + + onDragleave() { + if (this.folder || this.browser.folder) this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.browser.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + this.$root.api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return; + this.browser.removeFolder(folder.id); + this.$root.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }); + } + //#endregion + } + } +}); +</script> + +<style lang="scss" scoped> +.drylbebk { + > * { + pointer-events: none; + } + + &[data-draghover] { + background: #eee; + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/src/client/components/drive.vue b/src/client/components/drive.vue new file mode 100644 index 0000000000..2279e2eb6e --- /dev/null +++ b/src/client/components/drive.vue @@ -0,0 +1,664 @@ +<template> +<div class="yfudmmck"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <x-nav-folder :class="{ current: folder == null }"/> + <template v-for="folder in hierarchyFolders"> + <span class="separator"><fa :icon="faAngleRight"/></span> + <x-nav-folder :folder="folder" :key="folder.id"/> + </template> + <span class="separator" v-if="folder != null"><fa :icon="faAngleRight"/></span> + <span class="folder current" v-if="folder != null">{{ folder.name }}</span> + </div> + </nav> + <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" + ref="main" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + > + <div class="contents" ref="contents"> + <div class="folders" ref="foldersContainer" v-if="folders.length > 0"> + <x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <mk-button v-if="moreFolders">{{ $t('@.load-more') }}</mk-button> + </div> + <div class="files" ref="filesContainer" v-if="files.length > 0"> + <x-file v-for="file in files" :key="file.id" class="file" :file="file" :select-mode="selectMode"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <mk-button v-if="moreFiles" @click="fetchMoreFiles">{{ $t('@.load-more') }}</mk-button> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="draghover">{{ $t('empty-draghover') }}</p> + <p v-if="!draghover && folder == null"><strong>{{ $t('emptyDrive') }}</strong><br/>{{ $t('empty-drive-description') }}</p> + <p v-if="!draghover && folder != null">{{ $t('emptyFolder') }}</p> + </div> + </div> + <mk-loading v-if="fetching"/> + </div> + <div class="dropzone" v-if="draghover"></div> + <x-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/> + <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAngleRight } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XNavFolder from './drive.nav-folder.vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import XUploader from './uploader.vue'; +import MkButton from './ui/button.vue'; + +export default Vue.extend({ + i18n, + + components: { + XNavFolder, + XFolder, + XFile, + XUploader, + MkButton, + }, + + props: { + initFolder: { + type: Object, + required: false + }, + type: { + type: String, + required: false, + default: undefined + }, + multiple: { + type: Boolean, + required: false, + default: false + }, + selectMode: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + uploadings: [], + connection: null, + + /** + * ドロップされようとしているか + */ + draghover: false, + + /** + * 自信の所有するアイテムがドラッグをスタートさせたか + * (自分自身の階層にドロップできないようにするためのフラグ) + */ + isDragSource: false, + + fetching: true, + + faAngleRight + }; + }, + + watch: { + folder() { + this.$emit('cd', this.folder); + } + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('drive'); + + this.connection.on('fileCreated', this.onStreamDriveFileCreated); + this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); + this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); + this.connection.on('folderCreated', this.onStreamDriveFolderCreated); + this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); + this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted); + + if (this.initFolder) { + this.move(this.initFolder); + } else { + this.fetch(); + } + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onStreamDriveFileCreated(file) { + this.addFile(file, true); + }, + + onStreamDriveFileUpdated(file) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFileDeleted(fileId) { + this.removeFile(fileId); + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + onStreamDriveFolderDeleted(folderId) { + this.removeFolder(folderId); + }, + + onChangeUploaderUploads(uploads) { + this.uploadings = uploads; + }, + + onUploaderUploaded(file) { + this.addFile(file, true); + }, + + onDragover(e): any { + // ドラッグ元が自分自身の所有するアイテムだったら + if (this.isDragSource) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter(e) { + if (!this.isDragSource) this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): any { + this.draghover = false; + + // ドロップされてきたものがファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + if (this.files.some(f => f.id == file.id)) return; + this.removeFile(file.id); + this.$root.api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return false; + if (this.folders.some(f => f.id == folder.id)) return false; + this.removeFolder(folder.id); + this.$root.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + this.$root.dialog({ + title: this.$t('unable-to-process'), + text: this.$t('circular-reference-detected') + }); + break; + default: + this.$root.dialog({ + type: 'error', + text: this.$t('unhandled-error') + }); + } + }); + } + //#endregion + }, + + selectLocalFile() { + (this.$refs.fileInput as any).click(); + }, + + urlUpload() { + this.$root.dialog({ + title: this.$t('url-upload'), + input: { + placeholder: this.$t('url-of-file') + } + }).then(({ canceled, result: url }) => { + if (canceled) return; + this.$root.api('drive/files/upload_from_url', { + url: url, + folderId: this.folder ? this.folder.id : undefined + }); + + this.$root.dialog({ + title: this.$t('url-upload-requested'), + text: this.$t('may-take-time') + }); + }); + }, + + createFolder() { + this.$root.dialog({ + title: this.$t('create-folder'), + input: { + placeholder: this.$t('folder-name') + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + this.$root.api('drive/folders/create', { + name: name, + parentId: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }); + }, + + onChangeFileInput() { + for (const file of Array.from((this.$refs.fileInput as any).files)) { + this.upload(file, this.folder); + } + }, + + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + (this.$refs.uploader as any).upload(file, folder); + }, + + chooseFile(file) { + const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + if (isAlreadySelected) { + this.$emit('selected', file); + } else { + this.selectedFiles = [file]; + this.$emit('change-selection', [file]); + } + } + }, + + move(target) { + if (target == null) { + this.goRoot(); + return; + } else if (typeof target == 'object') { + target = target.id; + } + + this.fetching = true; + + this.$root.api('drive/folders/show', { + folderId: target + }).then(folder => { + this.folder = folder; + this.hierarchyFolders = []; + + const dive = folder => { + this.hierarchyFolders.unshift(folder); + if (folder.parent) dive(folder.parent); + }; + + if (folder.parent) dive(folder.parent); + + this.$emit('open-folder', folder); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) return; + + if (this.folders.some(f => f.id == folder.id)) { + const exist = this.folders.map(f => f.id).indexOf(folder.id); + Vue.set(this.folders, exist, folder); + return; + } + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + + appendFolder(folder) { + this.addFolder(folder); + }, + + prependFile(file) { + this.addFile(file, true); + }, + + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot() { + // 既にrootにいるなら何もしない + if (this.folder == null) return; + + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root'); + this.fetch(); + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 30; + const filesMax = 30; + + // フォルダ一覧取得 + this.$root.api('drive/folders', { + folderId: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + this.$root.api('drive/files', { + folderId: this.folder ? this.folder.id : null, + type: this.type, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + for (const x of fetchedFolders) this.appendFolder(x); + for (const x of fetchedFiles) this.appendFile(x); + this.fetching = false; + } else { + flag = true; + } + }; + }, + + fetchMoreFiles() { + this.fetching = true; + + const max = 30; + + // ファイル一覧取得 + this.$root.api('drive/files', { + folderId: this.folder ? this.folder.id : null, + type: this.type, + untilId: this.files[this.files.length - 1].id, + limit: max + 1 + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + for (const x of files) this.appendFile(x); + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.yfudmmck { + > nav { + display: block; + z-index: 2; + width: 100%; + overflow: auto; + font-size: 0.9em; + box-shadow: 0 1px 0 var(--divider); + + &, * { + user-select: none; + } + + > .path { + display: inline-block; + vertical-align: bottom; + line-height: 38px; + white-space: nowrap; + + > * { + display: inline-block; + margin: 0; + padding: 0 8px; + line-height: 38px; + cursor: pointer; + + * { + pointer-events: none; + } + + &:hover { + text-decoration: underline; + } + + &.current { + font-weight: bold; + cursor: default; + + &:hover { + text-decoration: none; + } + } + + &.separator { + margin: 0; + padding: 0; + opacity: 0.5; + cursor: default; + + > [data-icon] { + margin: 0; + } + } + } + } + } + + > .main { + padding: 8px 0; + overflow: auto; + + &, * { + user-select: none; + } + + &.fetching { + cursor: wait !important; + + * { + pointer-events: none; + } + + > .contents { + opacity: 0.5; + } + } + + &.uploading { + height: calc(100% - 38px - 100px); + } + + > .contents { + + > .folders, + > .files { + display: flex; + flex-wrap: wrap; + + > .folder, + > .file { + flex-grow: 1; + width: 144px; + margin: 4px; + box-sizing: border-box; + } + + > .padding { + flex-grow: 1; + pointer-events: none; + width: 144px + 8px; + } + } + + > .empty { + padding: 16px; + text-align: center; + pointer-events: none; + opacity: 0.5; + + > p { + margin: 0; + } + } + } + } + + > .dropzone { + position: absolute; + left: 0; + top: 38px; + width: 100%; + height: calc(100% - 38px); + border: dashed 2px var(--focus); + pointer-events: none; + } + + > .mk-uploader { + height: 100px; + padding: 16px; + } + + > input { + display: none; + } +} +</style> diff --git a/src/client/components/ellipsis.vue b/src/client/components/ellipsis.vue new file mode 100644 index 0000000000..0a46f486d6 --- /dev/null +++ b/src/client/components/ellipsis.vue @@ -0,0 +1,34 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="scss" scoped> +.mk-ellipsis { + > span { + animation: ellipsis 1.4s infinite ease-in-out both; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.16s; + } + + &:nth-child(3) { + animation-delay: 0.32s; + } + } +} + +@keyframes ellipsis { + 0%, 80%, 100% { + opacity: 1; + } + 40% { + opacity: 0; + } +} +</style> diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue new file mode 100644 index 0000000000..61d641a023 --- /dev/null +++ b/src/client/components/emoji-picker.vue @@ -0,0 +1,268 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }"> + <div class="omfetrab"> + <header> + <button v-for="category in categories" + class="_button" + :title="category.text" + @click="go(category)" + :class="{ active: category.isActive }" + :key="category.text" + > + <fa :icon="category.icon" fixed-width/> + </button> + </header> + + <div class="emojis"> + <template v-if="categories[0].isActive"> + <header class="category"><fa :icon="faHistory" fixed-width/> {{ $t('recentUsedEmojis') }}</header> + <div class="list"> + <button v-for="(emoji, i) in ($store.state.device.recentEmojis || [])" + class="_button" + :title="emoji.name" + @click="chosen(emoji)" + :key="i" + > + <mk-emoji v-if="emoji.char != null" :emoji="emoji.char"/> + <img v-else :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + </template> + + <header class="category"><fa :icon="categories.find(x => x.isActive).icon" fixed-width/> {{ categories.find(x => x.isActive).text }}</header> + <template v-if="categories.find(x => x.isActive).name"> + <div class="list"> + <button v-for="emoji in emojilist.filter(e => e.category === categories.find(x => x.isActive).name)" + class="_button" + :title="emoji.name" + @click="chosen(emoji)" + :key="emoji.name" + > + <mk-emoji :emoji="emoji.char"/> + </button> + </div> + </template> + <template v-else> + <div v-for="(key, i) in Object.keys(customEmojis)" :key="i"> + <header class="sub" v-if="key">{{ key }}</header> + <div class="list"> + <button v-for="emoji in customEmojis[key]" + class="_button" + :title="emoji.name" + @click="chosen(emoji)" + :key="emoji.name" + > + <img :src="$store.state.device.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + </div> + </template> + </div> + </div> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { emojilist } from '../../misc/emojilist'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { faAsterisk, faLeaf, faUtensils, faFutbol, faCity, faDice, faGlobe, faHistory } from '@fortawesome/free-solid-svg-icons'; +import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons'; +import { groupByX } from '../../prelude/array'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XPopup, + }, + + props: { + source: { + required: true + }, + }, + + data() { + return { + emojilist, + getStaticImageUrl, + customEmojis: {}, + faGlobe, faHistory, + categories: [{ + text: this.$t('customEmoji'), + icon: faAsterisk, + isActive: true + }, { + name: 'people', + text: this.$t('people'), + icon: faLaugh, + isActive: false + }, { + name: 'animals_and_nature', + text: this.$t('animals-and-nature'), + icon: faLeaf, + isActive: false + }, { + name: 'food_and_drink', + text: this.$t('food-and-drink'), + icon: faUtensils, + isActive: false + }, { + name: 'activity', + text: this.$t('activity'), + icon: faFutbol, + isActive: false + }, { + name: 'travel_and_places', + text: this.$t('travel-and-places'), + icon: faCity, + isActive: false + }, { + name: 'objects', + text: this.$t('objects'), + icon: faDice, + isActive: false + }, { + name: 'symbols', + text: this.$t('symbols'), + icon: faHeart, + isActive: false + }, { + name: 'flags', + text: this.$t('flags'), + icon: faFlag, + isActive: false + }] + }; + }, + + created() { + let local = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; + local = groupByX(local, (x: any) => x.category || ''); + this.customEmojis = local; + }, + + methods: { + go(category: any) { + this.goCategory(category.name); + }, + + goCategory(name: string) { + let matched = false; + for (const c of this.categories) { + c.isActive = c.name === name; + if (c.isActive) { + matched = true; + } + } + if (!matched) { + this.categories[0].isActive = true; + } + }, + + chosen(emoji: any) { + const getKey = (emoji: any) => emoji.char || `:${emoji.name}:`; + let recents = this.$store.state.device.recentEmojis || []; + recents = recents.filter((e: any) => getKey(e) !== getKey(emoji)); + recents.unshift(emoji) + this.$store.commit('device/set', { key: 'recentEmojis', value: recents.splice(0, 16) }); + this.$emit('chosen', getKey(emoji)); + }, + + close() { + this.$refs.popup.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + width: 350px; + + > header { + display: flex; + + > button { + flex: 1; + padding: 10px 0; + font-size: 16px; + transition: color 0.2s ease; + + &:hover { + color: var(--textHighlighted); + transition: color 0s; + } + + &.active { + color: var(--accent); + transition: color 0s; + } + } + } + + > .emojis { + height: 300px; + overflow-y: auto; + overflow-x: hidden; + + > header.category { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + background: var(--panel); + font-size: 12px; + } + + header.sub { + padding: 4px 8px; + font-size: 12px; + } + + div.list { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + gap: 4px; + padding: 8px; + + > button { + position: relative; + padding: 0; + width: 100%; + + &:before { + content: ''; + display: block; + width: 1px; + height: 0; + padding-bottom: 100%; + } + + &:hover { + > * { + transform: scale(1.2); + transition: transform 0s; + } + } + + > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + font-size: 28px; + transition: transform 0.2s ease; + pointer-events: none; + } + } + } + } +} +</style> diff --git a/src/client/components/emoji.vue b/src/client/components/emoji.vue new file mode 100644 index 0000000000..2e8bddb803 --- /dev/null +++ b/src/client/components/emoji.vue @@ -0,0 +1,132 @@ +<template> +<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt"/> +<img v-else-if="char && !useOsDefaultEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt"/> +<span v-else-if="char && useOsDefaultEmojis">{{ char }}</span> +<span v-else>:{{ name }}:</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; +import { twemojiSvgBase } from '../../misc/twemoji-base'; + +export default Vue.extend({ + props: { + name: { + type: String, + required: false + }, + emoji: { + type: String, + required: false + }, + normal: { + type: Boolean, + required: false, + default: false + }, + noStyle: { + type: Boolean, + required: false, + default: false + }, + customEmojis: { + required: false, + default: () => [] + }, + isReaction: { + type: Boolean, + default: false + }, + }, + + data() { + return { + url: null, + char: null, + customEmoji: null + } + }, + + computed: { + alt(): string { + return this.customEmoji ? `:${this.customEmoji.name}:` : this.char; + }, + + useOsDefaultEmojis(): boolean { + return this.$store.state.device.useOsDefaultEmojis && !this.isReaction; + } + }, + + watch: { + customEmojis() { + if (this.name) { + const customEmoji = this.customEmojis.find(x => x.name == this.name); + if (customEmoji) { + this.customEmoji = customEmoji; + this.url = this.$store.state.device.disableShowingAnimatedImages + ? getStaticImageUrl(customEmoji.url) + : customEmoji.url; + } + } + }, + }, + + created() { + if (this.name) { + const customEmoji = this.customEmojis.find(x => x.name == this.name); + if (customEmoji) { + this.customEmoji = customEmoji; + this.url = this.$store.state.device.disableShowingAnimatedImages + ? getStaticImageUrl(customEmoji.url) + : customEmoji.url; + } else { + //const emoji = lib[this.name]; + //if (emoji) { + // this.char = emoji.char; + //} + } + } else { + this.char = this.emoji; + } + + if (this.char) { + let codes = Array.from(this.char).map(x => x.codePointAt(0).toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); + + this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`; + } + }, +}); +</script> + +<style lang="scss" scoped> +.mk-emoji { + height: 1.25em; + vertical-align: -0.25em; + + &.custom { + height: 2.5em; + vertical-align: middle; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } + + &.normal { + height: 1.25em; + vertical-align: -0.25em; + + &:hover { + transform: none; + } + } + } + + &.noStyle { + height: auto !important; + } +} +</style> diff --git a/src/client/components/error.vue b/src/client/components/error.vue new file mode 100644 index 0000000000..1dc21dbb19 --- /dev/null +++ b/src/client/components/error.vue @@ -0,0 +1,42 @@ +<template> +<div class="wjqjnyhzogztorhrdgcpqlkxhkmuetgj _panel"> + <p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p> + <mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import MkButton from './ui/button.vue'; + +export default Vue.extend({ + i18n, + components: { + MkButton, + }, + data() { + return { + faExclamationTriangle + }; + }, +}); +</script> + +<style lang="scss" scoped> +.wjqjnyhzogztorhrdgcpqlkxhkmuetgj { + max-width: 350px; + margin: 0 auto; + padding: 32px; + text-align: center; + + > p { + margin: 0 0 8px 0; + } + + > .button { + margin: 0 auto; + } +} +</style> diff --git a/src/client/components/file-type-icon.vue b/src/client/components/file-type-icon.vue new file mode 100644 index 0000000000..8492567ad7 --- /dev/null +++ b/src/client/components/file-type-icon.vue @@ -0,0 +1,29 @@ +<template> +<span class="mk-file-type-icon"> + <template v-if="kind == 'image'"><fa :icon="faFileImage"/></template> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faFileImage } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + type: { + type: String, + required: true, + } + }, + data() { + return { + faFileImage + }; + }, + computed: { + kind(): string { + return this.type.split('/')[0]; + } + } +}); +</script> diff --git a/src/client/components/follow-button.vue b/src/client/components/follow-button.vue new file mode 100644 index 0000000000..4b57a2bd88 --- /dev/null +++ b/src/client/components/follow-button.vue @@ -0,0 +1,162 @@ +<template> +<button class="wfliddvnhxvyusikowhxozkyxyenqxqr _button" + :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait"> + <fa v-if="hasPendingFollowRequestFromYou && user.isLocked" :icon="faHourglassHalf"/> + <fa v-else-if="hasPendingFollowRequestFromYou && !user.isLocked" :icon="faSpinner" pulse/> + <fa v-else-if="isFollowing" :icon="faMinus"/> + <fa v-else-if="!isFollowing && user.isLocked" :icon="faPlus"/> + <fa v-else-if="!isFollowing && !user.isLocked" :icon="faPlus"/> + </template> + <template v-else><fa :icon="faSpinner" pulse fixed-width/></template> +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { faSpinner, faPlus, faMinus, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + i18n, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + isFollowing: this.user.isFollowing, + hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou, + wait: false, + connection: null, + faSpinner, faPlus, faMinus, faHourglassHalf + }; + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('main'); + + this.connection.on('follow', this.onFollowChange); + this.connection.on('unfollow', this.onFollowChange); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onFollowChange(user) { + if (user.id == this.user.id) { + this.isFollowing = user.isFollowing; + this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; + } + }, + + async onClick() { + this.wait = true; + + try { + if (this.isFollowing) { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }), + showCancelButton: true + }); + + if (canceled) return; + + await this.$root.api('following/delete', { + userId: this.user.id + }); + } else { + if (this.hasPendingFollowRequestFromYou) { + await this.$root.api('following/requests/cancel', { + userId: this.user.id + }); + } else if (this.user.isLocked) { + await this.$root.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } else { + await this.$root.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } + } + } catch (e) { + console.error(e); + } finally { + this.wait = false; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.wfliddvnhxvyusikowhxozkyxyenqxqr { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + width: 31px; + height: 31px; + font-size: 16px; + border-radius: 100%; + background: #fff; + + &:focus { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 100%; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } +} +</style> diff --git a/src/client/components/formula-core.vue b/src/client/components/formula-core.vue new file mode 100644 index 0000000000..45b27f9026 --- /dev/null +++ b/src/client/components/formula-core.vue @@ -0,0 +1,33 @@ + +<template> +<div v-if="block" v-html="compiledFormula"></div> +<span v-else v-html="compiledFormula"></span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as katex from 'katex'; +export default Vue.extend({ + props: { + formula: { + type: String, + required: true + }, + block: { + type: Boolean, + required: true + } + }, + computed: { + compiledFormula(): any { + return katex.renderToString(this.formula, { + throwOnError: false + } as any); + } + } +}); +</script> + +<style> +@import "../../../node_modules/katex/dist/katex.min.css"; +</style> diff --git a/src/client/components/formula.vue b/src/client/components/formula.vue new file mode 100644 index 0000000000..4aaad1bf3e --- /dev/null +++ b/src/client/components/formula.vue @@ -0,0 +1,22 @@ +<template> +<x-formula :formula="formula" :block="block" /> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + components: { + XFormula: () => import('./formula-core.vue').then(m => m.default) + }, + props: { + formula: { + type: String, + required: true + }, + block: { + type: Boolean, + required: true + } + } +}); +</script> diff --git a/src/client/components/google.vue b/src/client/components/google.vue new file mode 100644 index 0000000000..e6ef7f7d90 --- /dev/null +++ b/src/client/components/google.vue @@ -0,0 +1,71 @@ +<template> +<div class="mk-google"> + <input type="search" v-model="query" :placeholder="q"> + <button @click="search"><fa icon="search"/> {{ $t('@.search') }}</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + props: ['q'], + data() { + return { + query: null + }; + }, + mounted() { + this.query = this.q; + }, + methods: { + search() { + const engine = this.$store.state.settings.webSearchEngine || + 'https://www.google.com/?#q={{query}}'; + const url = engine.replace('{{query}}', this.query) + window.open(url, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-google { + display: flex; + margin: 8px 0; + + > input { + flex-shrink: 1; + padding: 10px; + width: 100%; + height: 40px; + font-size: 16px; + color: var(--googleSearchFg); + background: var(--googleSearchBg); + border: solid 1px var(--googleSearchBorder); + border-radius: 4px 0 0 4px; + + &:hover { + border-color: var(--googleSearchHoverBorder); + } + } + + > button { + flex-shrink: 0; + padding: 0 16px; + border: solid 1px var(--googleSearchBorder); + border-left: none; + border-radius: 0 4px 4px 0; + + &:hover { + background-color: var(--googleSearchHoverButton); + } + + &:active { + box-shadow: 0 2px 4px rgba(#000, 0.15) inset; + } + } +} +</style> diff --git a/src/client/components/index.ts b/src/client/components/index.ts new file mode 100644 index 0000000000..9385c2af73 --- /dev/null +++ b/src/client/components/index.ts @@ -0,0 +1,25 @@ +import Vue from 'vue'; + +import mfm from './misskey-flavored-markdown.vue'; +import acct from './acct.vue'; +import avatar from './avatar.vue'; +import emoji from './emoji.vue'; +import userName from './user-name.vue'; +import ellipsis from './ellipsis.vue'; +import time from './time.vue'; +import url from './url.vue'; +import loading from './loading.vue'; +import SequentialEntrance from './sequential-entrance.vue'; +import error from './error.vue'; + +Vue.component('mfm', mfm); +Vue.component('mk-acct', acct); +Vue.component('mk-avatar', avatar); +Vue.component('mk-emoji', emoji); +Vue.component('mk-user-name', userName); +Vue.component('mk-ellipsis', ellipsis); +Vue.component('mk-time', time); +Vue.component('mk-url', url); +Vue.component('mk-loading', loading); +Vue.component('mk-error', error); +Vue.component('sequential-entrance', SequentialEntrance); diff --git a/src/client/components/loading.vue b/src/client/components/loading.vue new file mode 100644 index 0000000000..88d1ed77fa --- /dev/null +++ b/src/client/components/loading.vue @@ -0,0 +1,30 @@ +<template> +<div class="yxspomdl"> + <fa :icon="faSpinner" pulse fixed-width class="icon"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + data() { + return { + faSpinner + }; + }, +}); +</script> + +<style lang="scss" scoped> +.yxspomdl { + padding: 32px; + text-align: center; + + > .icon { + font-size: 32px; + opacity: 0.5; + } +} +</style> diff --git a/src/client/components/media-banner.vue b/src/client/components/media-banner.vue new file mode 100644 index 0000000000..088c11fab7 --- /dev/null +++ b/src/client/components/media-banner.vue @@ -0,0 +1,109 @@ +<template> +<div class="mk-media-banner"> + <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> + <span class="icon"><fa :icon="faExclamationTriangle"/></span> + <b>{{ $t('sensitive') }}</b> + <span>{{ $t('clickToShow') }}</span> + </div> + <div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'"> + <audio class="audio" + :src="media.url" + :title="media.name" + controls + ref="audio" + @volumechange="volumechange" + preload="metadata" /> + </div> + <a class="download" v-else + :href="media.url" + :title="media.name" + :download="media.name" + > + <span class="icon"><fa icon="download"/></span> + <b>{{ media.name }}</b> + </a> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + props: { + media: { + type: Object, + required: true + } + }, + data() { + return { + hide: true, + faExclamationTriangle + }; + }, + mounted() { + const audioTag = this.$refs.audio as HTMLAudioElement; + if (audioTag) audioTag.volume = this.$store.state.device.mediaVolume; + }, + methods: { + volumechange() { + const audioTag = this.$refs.audio as HTMLAudioElement; + this.$store.commit('device/set', { key: 'mediaVolume', value: audioTag.volume }); + }, + }, +}) +</script> + +<style lang="scss" scoped> +.mk-media-banner { + width: 100%; + border-radius: 4px; + margin-top: 4px; + overflow: hidden; + + > .download, + > .sensitive { + display: flex; + align-items: center; + font-size: 12px; + padding: 8px 12px; + white-space: nowrap; + + > * { + display: block; + } + + > b { + overflow: hidden; + text-overflow: ellipsis; + } + + > *:not(:last-child) { + margin-right: .2em; + } + + > .icon { + font-size: 1.6em; + } + } + + > .download { + background: var(--noteAttachedFile); + } + + > .sensitive { + background: #111; + color: #fff; + } + + > .audio { + .audio { + display: block; + width: 100%; + } + } +} +</style> diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue new file mode 100644 index 0000000000..5ae167d490 --- /dev/null +++ b/src/client/components/media-image.vue @@ -0,0 +1,113 @@ +<template> +<div class="qjewsnkgzzxlxtzncydssfbgjibiehcy" v-if="image.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> + <div> + <b><fa :icon="faExclamationTriangle"/> {{ $t('sensitive') }}</b> + <span>{{ $t('clickToShow') }}</span> + </div> +</div> +<a class="gqnyydlzavusgskkfvwvjiattxdzsqlf" v-else + :href="image.url" + :style="style" + :title="image.name" + @click.prevent="onClick" +> + <div v-if="image.type === 'image/gif'">GIF</div> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import { getStaticImageUrl } from '../scripts/get-static-image-url'; + +export default Vue.extend({ + i18n, + props: { + image: { + type: Object, + required: true + }, + raw: { + default: false + } + }, + data() { + return { + hide: true, + faExclamationTriangle + }; + }, + computed: { + style(): any { + let url = `url(${ + this.$store.state.device.disableShowingAnimatedImages + ? getStaticImageUrl(this.image.thumbnailUrl) + : this.image.thumbnailUrl + })`; + + if (this.$store.state.device.loadRemoteMedia) { + url = null; + } else if (this.raw || this.$store.state.device.loadRawImages) { + url = `url(${this.image.url})`; + } + + return { + 'background-color': this.image.properties.avgColor || 'transparent', + 'background-image': url + }; + } + }, + methods: { + onClick() { + window.open(this.image.url, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.gqnyydlzavusgskkfvwvjiattxdzsqlf { + display: block; + cursor: zoom-in; + overflow: hidden; + width: 100%; + height: 100%; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + + > div { + background-color: var(--fg); + border-radius: 6px; + color: var(--secondary); + display: inline-block; + font-size: 14px; + font-weight: bold; + left: 12px; + opacity: .5; + padding: 0 6px; + text-align: center; + top: 12px; + pointer-events: none; + } +} + +.qjewsnkgzzxlxtzncydssfbgjibiehcy { + display: flex; + justify-content: center; + align-items: center; + background: #111; + color: #fff; + + > div { + display: table-cell; + text-align: center; + font-size: 12px; + + > * { + display: block; + } + } +} +</style> diff --git a/src/client/components/media-list.vue b/src/client/components/media-list.vue new file mode 100644 index 0000000000..08722ff91a --- /dev/null +++ b/src/client/components/media-list.vue @@ -0,0 +1,130 @@ +<template> +<div class="mk-media-list"> + <template v-for="media in mediaList.filter(media => !previewable(media))"> + <x-banner :media="media" :key="media.id"/> + </template> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> + <div :data-count="mediaList.filter(media => previewable(media)).length" ref="grid"> + <template v-for="media in mediaList"> + <x-video :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> + <x-image :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XBanner from './media-banner.vue'; +import XImage from './media-image.vue'; +import XVideo from './media-video.vue'; + +export default Vue.extend({ + components: { + XBanner, + XImage, + XVideo, + }, + props: { + mediaList: { + required: true + }, + raw: { + default: false + } + }, + mounted() { + //#region for Safari bug + if (this.$refs.grid) { + this.$refs.grid.style.height = this.$refs.grid.clientHeight ? `${this.$refs.grid.clientHeight}px` + : '287px'; + } + //#endregion + }, + methods: { + previewable(file) { + return file.type.startsWith('video') || file.type.startsWith('image'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-media-list { + > .gird-container { + position: relative; + width: 100%; + margin-top: 4px; + + &:before { + content: ''; + display: block; + padding-top: 56.25% // 16:9; + } + + > div { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-gap: 4px; + + > * { + overflow: hidden; + border-radius: 4px; + } + + &[data-count="1"] { + grid-template-rows: 1fr; + } + + &[data-count="2"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + } + + &[data-count="3"] { + grid-template-columns: 1fr 0.5fr; + grid-template-rows: 1fr 1fr; + + > *:nth-child(1) { + grid-row: 1 / 3; + } + + > *:nth-child(3) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + + &[data-count="4"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + } + + > *:nth-child(1) { + grid-column: 1 / 2; + grid-row: 1 / 2; + } + + > *:nth-child(2) { + grid-column: 2 / 3; + grid-row: 1 / 2; + } + + > *:nth-child(3) { + grid-column: 1 / 2; + grid-row: 2 / 3; + } + + > *:nth-child(4) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + } +} +</style> diff --git a/src/client/components/media-video.vue b/src/client/components/media-video.vue new file mode 100644 index 0000000000..f96e902976 --- /dev/null +++ b/src/client/components/media-video.vue @@ -0,0 +1,79 @@ +<template> +<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="video.isSensitive && hide && !$store.state.device.alwaysShowNsfw" @click="hide = false"> + <div> + <b><fa icon="exclamation-triangle"/> {{ $t('sensitive') }}</b> + <span>{{ $t('clickToShow') }}</span> + </div> +</div> +<a class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else + :href="video.url" + rel="nofollow noopener" + target="_blank" + :style="imageStyle" + :title="video.name" +> + <fa :icon="faPlayCircle"/> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlayCircle } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + props: { + video: { + type: Object, + required: true + } + }, + data() { + return { + hide: true, + faPlayCircle + }; + }, + computed: { + imageStyle(): any { + return { + 'background-image': `url(${this.video.thumbnailUrl})` + }; + } + } +}); +</script> + +<style lang="scss" scoped> +.kkjnbbplepmiyuadieoenjgutgcmtsvu { + display: flex; + justify-content: center; + align-items: center; + + font-size: 3.5em; + overflow: hidden; + background-position: center; + background-size: cover; + width: 100%; + height: 100%; +} + +.icozogqfvdetwohsdglrbswgrejoxbdj { + display: flex; + justify-content: center; + align-items: center; + background: #111; + color: #fff; + + > div { + display: table-cell; + text-align: center; + font-size: 12px; + + > b { + display: block; + } + } +} +</style> diff --git a/src/client/components/mention.vue b/src/client/components/mention.vue new file mode 100644 index 0000000000..06dcf12887 --- /dev/null +++ b/src/client/components/mention.vue @@ -0,0 +1,82 @@ +<template> +<router-link class="ldlomzub" :to="url" v-user-preview="canonical" v-if="url.startsWith('/')"> + <span class="me" v-if="isMe">{{ $t('you') }}</span> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host" v-if="(host != localHost) || $store.state.settings.showFullAcct">@{{ toUnicode(host) }}</span> + </span> +</router-link> +<a class="ldlomzub" :href="url" target="_blank" rel="noopener" v-else> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host">@{{ toUnicode(host) }}</span> + </span> +</a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { toUnicode } from 'punycode'; +import { host as localHost } from '../config'; + +export default Vue.extend({ + i18n, + props: { + username: { + type: String, + required: true + }, + host: { + type: String, + required: true + } + }, + data() { + return { + localHost + }; + }, + computed: { + url(): string { + switch (this.host) { + case 'twitter.com': + case 'github.com': + return `https://${this.host}/${this.username}`; + default: + return `/${this.canonical}`; + } + }, + canonical(): string { + return this.host === localHost ? `@${this.username}` : `@${this.username}@${toUnicode(this.host)}`; + }, + isMe(): boolean { + return this.$store.getters.isSignedIn && ( + `@${this.username}@${toUnicode(this.host)}` === `@${this.$store.state.i.username}@${toUnicode(localHost)}`.toLowerCase() + ); + } + }, + methods: { + toUnicode + } +}); +</script> + +<style lang="scss" scoped> +.ldlomzub { + color: var(--mention); + + > .me { + pointer-events: none; + user-select: none; + font-size: 70%; + vertical-align: top; + } + + > .main { + > .host { + opacity: 0.5; + } + } +} +</style> diff --git a/src/client/components/menu.vue b/src/client/components/menu.vue new file mode 100644 index 0000000000..c1c5ceaee7 --- /dev/null +++ b/src/client/components/menu.vue @@ -0,0 +1,165 @@ +<template> +<x-popup :source="source" :no-center="noCenter" :fixed="fixed" :width="width" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }"> + <sequential-entrance class="rrevdjwt" :class="{ left: align === 'left' }" :delay="15" :direction="direction"> + <template v-for="(item, i) in items.filter(item => item !== undefined)"> + <div v-if="item === null" class="divider" :key="i" :data-index="i"></div> + <span v-else-if="item.type === 'label'" class="label item" :key="i" :data-index="i"> + <span>{{ item.text }}</span> + </span> + <router-link v-else-if="item.type === 'link'" :to="item.to" @click.native="close()" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <fa v-if="item.icon" :icon="item.icon" fixed-width/> + <mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </router-link> + <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <fa v-if="item.icon" :icon="item.icon" fixed-width/> + <span>{{ item.text }}</span> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </a> + <button v-else-if="item.type === 'user'" @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <mk-avatar :user="item.user" class="avatar"/><mk-user-name :user="item.user"/> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </button> + <button v-else @click="clicked(item.action)" :tabindex="i" class="_button item" :key="i" :data-index="i"> + <fa v-if="item.icon" :icon="item.icon" fixed-width/> + <mk-avatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <i v-if="item.indicate"><fa :icon="faCircle"/></i> + </button> + </template> + </sequential-entrance> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCircle } from '@fortawesome/free-solid-svg-icons'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + components: { + XPopup + }, + props: { + source: { + required: true + }, + items: { + type: Array, + required: true + }, + align: { + type: String, + required: false + }, + noCenter: { + type: Boolean, + required: false + }, + fixed: { + type: Boolean, + required: false + }, + width: { + type: Number, + required: false + }, + direction: { + type: String, + required: false + }, + }, + data() { + return { + faCircle + }; + }, + methods: { + clicked(fn) { + fn(); + this.close(); + }, + close() { + this.$refs.popup.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +@keyframes blink { + 0% { opacity: 1; } + 30% { opacity: 1; } + 90% { opacity: 0; } +} + +.rrevdjwt { + padding: 8px 0; + + &.left { + > .item { + text-align: left; + } + } + + > .item { + display: block; + padding: 8px 16px; + width: 100%; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.9em; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + color: #fff; + background: var(--accent); + text-decoration: none; + } + + &:active { + color: #fff; + background: var(--accentDarken); + } + + &.label { + pointer-events: none; + font-size: 0.7em; + padding-bottom: 4px; + + > span { + opacity: 0.7; + } + } + + > [data-icon] { + margin-right: 4px; + width: 20px; + } + + > .avatar { + margin-right: 4px; + width: 20px; + height: 20px; + } + + > i { + position: absolute; + top: 5px; + left: 13px; + color: var(--accent); + font-size: 12px; + animation: blink 1s infinite; + } + } + + > .divider { + margin: 8px 0; + height: 1px; + background: var(--divider); + } +} +</style> diff --git a/src/client/components/mfm.ts b/src/client/components/mfm.ts new file mode 100644 index 0000000000..932beb907f --- /dev/null +++ b/src/client/components/mfm.ts @@ -0,0 +1,299 @@ +import Vue, { VNode } from 'vue'; +import { MfmForest } from '../../mfm/types'; +import { parse, parsePlain } from '../../mfm/parse'; +import MkUrl from './url.vue'; +import MkMention from './mention.vue'; +import { concat } from '../../prelude/array'; +import MkFormula from './formula.vue'; +import MkCode from './code.vue'; +import MkGoogle from './google.vue'; +import { host } from '../config'; + +export default Vue.component('misskey-flavored-markdown', { + props: { + text: { + type: String, + required: true + }, + plain: { + type: Boolean, + default: false + }, + nowrap: { + type: Boolean, + default: false + }, + author: { + type: Object, + default: null + }, + i: { + type: Object, + default: null + }, + customEmojis: { + required: false, + }, + isNote: { + type: Boolean, + default: true + }, + }, + + render(createElement) { + if (this.text == null || this.text == '') return; + + const ast = (this.plain ? parsePlain : parse)(this.text); + + const genEl = (ast: MfmForest) => concat(ast.map((token): VNode[] => { + switch (token.node.type) { + case 'text': { + const text = token.node.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + + if (!this.plain) { + const x = text.split('\n') + .map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]); + x[x.length - 1].pop(); + return x; + } else { + return [createElement('span', text.replace(/\n/g, ' '))]; + } + } + + case 'bold': { + return [createElement('b', genEl(token.children))]; + } + + case 'strike': { + return [createElement('del', genEl(token.children))]; + } + + case 'italic': { + return (createElement as any)('i', { + attrs: { + style: 'font-style: oblique;' + }, + }, genEl(token.children)); + } + + case 'big': { + return (createElement as any)('strong', { + attrs: { + style: `display: inline-block; font-size: 150% };` + }, + directives: [this.$store.state.settings.disableAnimatedMfm ? {} : { + name: 'animate-css', + value: { classes: 'tada', iteration: 'infinite' } + }] + }, genEl(token.children)); + } + + case 'small': { + return [createElement('small', { + attrs: { + style: 'opacity: 0.7;' + }, + }, genEl(token.children))]; + } + + case 'center': { + return [createElement('div', { + attrs: { + style: 'text-align:center;' + } + }, genEl(token.children))]; + } + + case 'motion': { + return (createElement as any)('span', { + attrs: { + style: 'display: inline-block;' + }, + directives: [this.$store.state.settings.disableAnimatedMfm ? {} : { + name: 'animate-css', + value: { classes: 'rubberBand', iteration: 'infinite' } + }] + }, genEl(token.children)); + } + + case 'spin': { + const direction = + token.node.props.attr == 'left' ? 'reverse' : + token.node.props.attr == 'alternate' ? 'alternate' : + 'normal'; + const style = (this.$store.state.settings.disableAnimatedMfm) + ? '' + : `animation: spin 1.5s linear infinite; animation-direction: ${direction};`; + return (createElement as any)('span', { + attrs: { + style: 'display: inline-block;' + style + }, + }, genEl(token.children)); + } + + case 'jump': { + return (createElement as any)('span', { + attrs: { + style: (this.$store.state.settings.disableAnimatedMfm) ? 'display: inline-block;' : 'display: inline-block; animation: jump 0.75s linear infinite;' + }, + }, genEl(token.children)); + } + + case 'flip': { + return (createElement as any)('span', { + attrs: { + style: 'display: inline-block; transform: scaleX(-1);' + }, + }, genEl(token.children)); + } + + case 'url': { + return [createElement(MkUrl, { + key: Math.random(), + props: { + url: token.node.props.url, + rel: 'nofollow noopener', + }, + attrs: { + style: 'color:var(--link);' + } + })]; + } + + case 'link': { + return [createElement('a', { + attrs: { + class: 'link', + href: token.node.props.url, + rel: 'nofollow noopener', + target: '_blank', + title: token.node.props.url, + style: 'color:var(--link);' + } + }, genEl(token.children))]; + } + + case 'mention': { + return [createElement(MkMention, { + key: Math.random(), + props: { + host: (token.node.props.host == null && this.author && this.author.host != null ? this.author.host : token.node.props.host) || host, + username: token.node.props.username + } + })]; + } + + case 'hashtag': { + return [createElement('router-link', { + key: Math.random(), + attrs: { + to: this.isNote ? `/tags/${encodeURIComponent(token.node.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.node.props.hashtag)}`, + style: 'color:var(--hashtag);' + } + }, `#${token.node.props.hashtag}`)]; + } + + case 'blockCode': { + return [createElement(MkCode, { + key: Math.random(), + props: { + code: token.node.props.code, + lang: token.node.props.lang, + } + })]; + } + + case 'inlineCode': { + return [createElement(MkCode, { + key: Math.random(), + props: { + code: token.node.props.code, + lang: token.node.props.lang, + inline: true + } + })]; + } + + case 'quote': { + if (this.shouldBreak) { + return [createElement('div', { + attrs: { + class: 'quote' + } + }, genEl(token.children))]; + } else { + return [createElement('span', { + attrs: { + class: 'quote' + } + }, genEl(token.children))]; + } + } + + case 'title': { + return [createElement('div', { + attrs: { + class: 'title' + } + }, genEl(token.children))]; + } + + case 'emoji': { + const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; + return [createElement('mk-emoji', { + key: Math.random(), + attrs: { + emoji: token.node.props.emoji, + name: token.node.props.name + }, + props: { + customEmojis: this.customEmojis || customEmojis, + normal: this.plain + } + })]; + } + + case 'mathInline': { + //const MkFormula = () => import('./formula.vue').then(m => m.default); + return [createElement(MkFormula, { + key: Math.random(), + props: { + formula: token.node.props.formula, + block: false + } + })]; + } + + case 'mathBlock': { + //const MkFormula = () => import('./formula.vue').then(m => m.default); + return [createElement(MkFormula, { + key: Math.random(), + props: { + formula: token.node.props.formula, + block: true + } + })]; + } + + case 'search': { + //const MkGoogle = () => import('./google.vue').then(m => m.default); + return [createElement(MkGoogle, { + key: Math.random(), + props: { + q: token.node.props.query + } + })]; + } + + default: { + console.log('unknown ast type:', token.node.type); + + return []; + } + } + })); + + // Parse ast to DOM + return createElement('span', genEl(ast)); + } +}); diff --git a/src/client/components/misskey-flavored-markdown.vue b/src/client/components/misskey-flavored-markdown.vue new file mode 100644 index 0000000000..c8eee8c126 --- /dev/null +++ b/src/client/components/misskey-flavored-markdown.vue @@ -0,0 +1,35 @@ +<template> +<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }" v-once/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MfmCore from './mfm'; + +export default Vue.extend({ + components: { + MfmCore + } +}); +</script> + +<style lang="scss" scoped> +.havbbuyv { + white-space: pre-wrap; + + &.nowrap { + white-space: pre; + word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html + overflow: hidden; + text-overflow: ellipsis; + } + + ::v-deep .quote { + display: block; + margin: 8px; + padding: 6px 0 6px 12px; + color: var(--mfmQuote); + border-left: solid 3px var(--mfmQuoteLine); + } +} +</style> diff --git a/src/client/components/modal.vue b/src/client/components/modal.vue new file mode 100644 index 0000000000..b7e6a336d7 --- /dev/null +++ b/src/client/components/modal.vue @@ -0,0 +1,84 @@ +<template> +<div class="mk-modal"> + <transition name="bg-fade" appear> + <div class="bg" ref="bg" v-if="show" @click="close()"></div> + </transition> + <transition name="modal" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> + <div class="content" ref="content" v-if="show" @click.self="close()"><slot></slot></div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + }, + data() { + return { + show: true, + }; + }, + methods: { + close() { + this.show = false; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.content as any).style.pointerEvents = 'none'; + } + } +}); +</script> + +<style lang="scss" scoped> +.modal-enter-active, .modal-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.modal-enter, .modal-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.bg-fade-enter-active, .bg-fade-leave-active { + transition: opacity 0.3s !important; +} +.bg-fade-enter, .bg-fade-leave-to { + opacity: 0; +} + +.mk-modal { + > .bg { + position: fixed; + top: 0; + left: 0; + z-index: 10000; + width: 100%; + height: 100%; + background: var(--modalBg) + } + + > .content { + position: fixed; + z-index: 10000; + top: 0; + bottom: 0; + left: 0; + right: 0; + max-width: calc(100% - 16px); + max-height: calc(100% - 16px); + overflow: auto; + margin: auto; + + ::v-deep > * { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + max-height: 100%; + max-width: 100%; + } + } +} +</style> diff --git a/src/client/components/note-header.vue b/src/client/components/note-header.vue new file mode 100644 index 0000000000..30ecb80834 --- /dev/null +++ b/src/client/components/note-header.vue @@ -0,0 +1,99 @@ +<template> +<header class="kkwtjztg"> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id"> + <mk-user-name :user="note.user"/> + </router-link> + <span class="is-bot" v-if="note.user.isBot">bot</span> + <span class="username"><mk-acct :user="note.user"/></span> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><fa :icon="faMobileAlt"/></span> + <router-link class="created-at" :to="note | notePage"> + <mk-time :time="note.createdAt"/> + </router-link> + <span class="visibility" v-if="note.visibility != 'public'"> + <fa v-if="note.visibility == 'home'" :icon="faHome"/> + <fa v-if="note.visibility == 'followers'" :icon="faUnlock"/> + <fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/> + </span> + </div> +</header> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faHome, faUnlock, faEnvelope, faMobileAlt } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + note: { + type: Object, + required: true + }, + }, + + data() { + return { + faHome, faUnlock, faEnvelope, faMobileAlt + }; + } +}); +</script> + +<style lang="scss" scoped> +.kkwtjztg { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + color: var(--noteHeaderName); + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + color: var(--noteHeaderBadgeFg); + background: var(--noteHeaderBadgeBg); + border-radius: 3px; + } + + > .username { + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--noteHeaderAcct); + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > * { + color: var(--noteHeaderInfo); + } + + > .mobile { + margin-right: 8px; + } + + > .visibility { + margin-left: 8px; + } + } +} +</style> diff --git a/src/client/components/note-menu.vue b/src/client/components/note-menu.vue new file mode 100644 index 0000000000..dd7b062f15 --- /dev/null +++ b/src/client/components/note-menu.vue @@ -0,0 +1,199 @@ +<template> +<x-menu :source="source" :items="items" @closed="closed"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faStar, faLink, faThumbtack, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import { url } from '../config'; +import copyToClipboard from '../scripts/copy-to-clipboard'; +import XMenu from './menu.vue'; + +export default Vue.extend({ + i18n, + components: { + XMenu + }, + props: ['note', 'source'], + data() { + return { + isFavorited: false, + isWatching: false + }; + }, + computed: { + items(): any[] { + if (this.$store.getters.isSignedIn) { + return [{ + icon: faCopy, + text: this.$t('copyContent'), + action: this.copyContent + }, { + icon: faLink, + text: this.$t('copyLink'), + action: this.copyLink + }, this.note.uri ? { + icon: faExternalLinkSquareAlt, + text: this.$t('showOnRemote'), + action: () => { + window.open(this.note.uri, '_blank'); + } + } : undefined, + null, + this.isFavorited ? { + icon: faStar, + text: this.$t('unfavorite'), + action: () => this.toggleFavorite(false) + } : { + icon: faStar, + text: this.$t('favorite'), + action: () => this.toggleFavorite(true) + }, + this.note.userId != this.$store.state.i.id ? this.isWatching ? { + icon: faEyeSlash, + text: this.$t('unwatch'), + action: () => this.toggleWatch(false) + } : { + icon: faEye, + text: this.$t('watch'), + action: () => this.toggleWatch(true) + } : undefined, + this.note.userId == this.$store.state.i.id ? (this.$store.state.i.pinnedNoteIds || []).includes(this.note.id) ? { + icon: faThumbtack, + text: this.$t('unpin'), + action: () => this.togglePin(false) + } : { + icon: faThumbtack, + text: this.$t('pin'), + action: () => this.togglePin(true) + } : undefined, + ...(this.note.userId == this.$store.state.i.id ? [ + null, + { + icon: faTrashAlt, + text: this.$t('delete'), + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + return [{ + icon: faCopy, + text: this.$t('copyContent'), + action: this.copyContent + }, { + icon: faLink, + text: this.$t('copyLink'), + action: this.copyLink + }, this.note.uri ? { + icon: faExternalLinkSquareAlt, + text: this.$t('showOnRemote'), + action: () => { + window.open(this.note.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + } + }, + + created() { + this.$root.api('notes/state', { + noteId: this.note.id + }).then(state => { + this.isFavorited = state.isFavorited; + this.isWatching = state.isWatching; + }); + }, + + methods: { + copyContent() { + copyToClipboard(this.note.text); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.note.id}`); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, + + togglePin(pin: boolean) { + this.$root.api(pin ? 'i/pin' : 'i/unpin', { + noteId: this.note.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$emit('closed'); + this.destroyDom(); + }).catch(e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + this.$root.dialog({ + type: 'error', + text: this.$t('pinLimitExceeded') + }); + } + }); + }, + + del() { + this.$root.dialog({ + type: 'warning', + text: this.$t('noteDeleteConfirm'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('notes/delete', { + noteId: this.note.id + }).then(() => { + this.$emit('closed'); + this.destroyDom(); + }); + }); + }, + + toggleFavorite(favorite: boolean) { + this.$root.api(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.note.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$emit('closed'); + this.destroyDom(); + }); + }, + + toggleWatch(watch: boolean) { + this.$root.api(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.note.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.destroyDom(); + }); + }, + + closed() { + this.$emit('closed'); + this.$nextTick(() => { + this.destroyDom(); + }); + } + } +}); +</script> diff --git a/src/client/components/note-preview.vue b/src/client/components/note-preview.vue new file mode 100644 index 0000000000..17ff5be868 --- /dev/null +++ b/src/client/components/note-preview.vue @@ -0,0 +1,121 @@ +<template> +<div class="yohlumlkhizgfkvvscwfcrcggkotpvry"> + <mk-avatar class="avatar" :user="note.user"/> + <div class="main"> + <x-note-header class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <x-cw-button v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <x-sub-note-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; + +export default Vue.extend({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.yohlumlkhizgfkvvscwfcrcggkotpvry { + display: flex; + margin: 0; + padding: 0; + overflow: hidden; + font-size: 10px; + + @media (min-width: 350px) { + font-size: 12px; + } + + @media (min-width: 500px) { + font-size: 14px; + } + + > .avatar { + + @media (min-width: 350px) { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + + @media (min-width: 500px) { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + color: var(--noteText); + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + color: var(--subNoteText); + } + } + } + } +} +</style> diff --git a/src/client/components/note.sub.vue b/src/client/components/note.sub.vue new file mode 100644 index 0000000000..7f6f972896 --- /dev/null +++ b/src/client/components/note.sub.vue @@ -0,0 +1,108 @@ +<template> +<div class="zlrxdaqttccpwhpaagdmkawtzklsccam"> + <mk-avatar class="avatar" :user="note.user"/> + <div class="main"> + <x-note-header class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis" /> + <x-cw-button v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <x-sub-note-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; + +export default Vue.extend({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + }, + // TODO + truncate: { + type: Boolean, + default: true + } + }, + + inject: { + narrow: { + default: false + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.zlrxdaqttccpwhpaagdmkawtzklsccam { + display: flex; + padding: 16px 32px; + font-size: 0.9em; + background: rgba(0, 0, 0, 0.03); + + @media (max-width: 450px) { + padding: 14px 16px; + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 38px; + height: 38px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/src/client/components/note.vue b/src/client/components/note.vue new file mode 100644 index 0000000000..8b3fa61a65 --- /dev/null +++ b/src/client/components/note.vue @@ -0,0 +1,729 @@ +<template> +<div + class="note _panel" + v-show="appearNote.deletedAt == null && !hideThisNote" + :tabindex="appearNote.deletedAt == null ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" + v-size="[{ max: 500 }, { max: 450 }, { max: 350 }, { max: 300 }]" +> + <x-sub v-for="note in conversation" :key="note.id" :note="note"/> + <x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div> + <div class="renote" v-if="isRenote"> + <mk-avatar class="avatar" :user="note.user"/> + <fa :icon="faRetweet"/> + <i18n path="renotedBy" tag="span"> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId" place="user"> + <mk-user-name :user="note.user"/> + </router-link> + </i18n> + <div class="info"> + <mk-time :time="note.createdAt"/> + <span class="visibility" v-if="note.visibility != 'public'"> + <fa v-if="note.visibility == 'home'" :icon="faHome"/> + <fa v-if="note.visibility == 'followers'" :icon="faUnlock"/> + <fa v-if="note.visibility == 'specified'" :icon="faEnvelope"/> + </span> + </div> + </div> + <article class="article"> + <mk-avatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <x-note-header class="header" :note="appearNote" :mini="true"/> + <div class="body" v-if="appearNote.deletedAt == null"> + <p v-if="appearNote.cw != null" class="cw"> + <mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis" /> + <x-cw-button v-model="showContent" :note="appearNote"/> + </p> + <div class="content" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> + <router-link class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><fa :icon="faReply"/></router-link> + <mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <x-media-list :media-list="appearNote.files"/> + </div> + <x-poll v-if="appearNote.poll" :note="appearNote" ref="pollViewer"/> + <x-url-preview v-for="url in urls" :url="url" :key="url" :compact="true" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><x-note-preview :note="appearNote.renote"/></div> + </div> + </div> + <footer v-if="appearNote.deletedAt == null" class="footer"> + <x-reactions-viewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><fa :icon="faReplyAll"/></template> + <template v-else><fa :icon="faReply"/></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" class="button _button" ref="renoteButton"> + <fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <fa :icon="faBan"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <fa :icon="faPlus"/> + </button> + <button v-if="!isMyNote && appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <fa :icon="faMinus"/> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <fa :icon="faEllipsisH"/> + </button> + </footer> + <div class="deleted" v-if="appearNote.deletedAt != null">{{ $t('deleted') }}</div> + </div> + </article> + <x-sub v-for="note in replies" :key="note.id" :note="note"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan } from '@fortawesome/free-solid-svg-icons'; +import { parse } from '../../mfm/parse'; +import { sum, unique } from '../../prelude/array'; +import i18n from '../i18n'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNotePreview from './note-preview.vue'; +import XReactionsViewer from './reactions-viewer.vue'; +import XMediaList from './media-list.vue'; +import XCwButton from './cw-button.vue'; +import XPoll from './poll.vue'; +import XUrlPreview from './url-preview.vue'; +import MkNoteMenu from './note-menu.vue'; +import MkReactionPicker from './reaction-picker.vue'; +import MkRenotePicker from './renote-picker.vue'; +import pleaseLogin from '../scripts/please-login'; + +function focus(el, fn) { + const target = fn(el); + if (target) { + if (target.hasAttribute('tabindex')) { + target.focus(); + } else { + focus(target, fn); + } + } +} + +export default Vue.extend({ + i18n, + + components: { + XSub, + XNoteHeader, + XNotePreview, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + XUrlPreview, + }, + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + return { + connection: null, + conversation: [], + replies: [], + showContent: false, + hideThisNote: false, + openingMenu: false, + faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan + }; + }, + + computed: { + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.$store.state.settings.reactions[0]), + '2': () => this.reactDirectly(this.$store.state.settings.reactions[1]), + '3': () => this.reactDirectly(this.$store.state.settings.reactions[2]), + '4': () => this.reactDirectly(this.$store.state.settings.reactions[3]), + '5': () => this.reactDirectly(this.$store.state.settings.reactions[4]), + '6': () => this.reactDirectly(this.$store.state.settings.reactions[5]), + '7': () => this.reactDirectly(this.$store.state.settings.reactions[6]), + '8': () => this.reactDirectly(this.$store.state.settings.reactions[7]), + '9': () => this.reactDirectly(this.$store.state.settings.reactions[8]), + '0': () => this.reactDirectly(this.$store.state.settings.reactions[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId); + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + title(): string { + return ''; + }, + + urls(): string[] { + if (this.appearNote.text) { + const ast = parse(this.appearNote.text); + // TODO: 再帰的にURL要素がないか調べる + const urls = unique(ast + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); + + // unique without hash + // [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] + const removeHash = x => x.replace(/#[^#]*$/, ''); + + return urls.reduce((array, url) => { + const removed = removeHash(url); + if (!array.map(x => removeHash(x)).includes(removed)) array.push(url); + return array; + }, []); + } else { + return null; + } + } + }, + + created() { + if (this.$store.getters.isSignedIn) { + this.connection = this.$root.stream; + } + + if (this.detail) { + this.$root.api('notes/children', { + noteId: this.appearNote.id, + limit: 30 + }).then(replies => { + this.replies = replies; + }); + + if (this.appearNote.replyId) { + this.$root.api('notes/conversation', { + noteId: this.appearNote.replyId + }).then(conversation => { + this.conversation = conversation.reverse(); + }); + } + } + }, + + mounted() { + this.capture(true); + + if (this.$store.getters.isSignedIn) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeDestroy() { + this.decapture(true); + + if (this.$store.getters.isSignedIn) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + capture(withHandler = false) { + if (this.$store.getters.isSignedIn) { + this.connection.send('sn', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$store.getters.isSignedIn) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + if (this.appearNote.reactions == null) { + Vue.set(this.appearNote, 'reactions', {}); + } + + if (this.appearNote.reactions[reaction] == null) { + Vue.set(this.appearNote.reactions, reaction, 0); + } + + // Increment the count + this.appearNote.reactions[reaction]++; + + if (body.userId == this.$store.state.i.id) { + Vue.set(this.appearNote, 'myReaction', reaction); + } + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + if (this.appearNote.reactions == null) { + return; + } + + if (this.appearNote.reactions[reaction] == null) { + return; + } + + // Decrement the count + if (this.appearNote.reactions[reaction] > 0) this.appearNote.reactions[reaction]--; + + if (body.userId == this.$store.state.i.id) { + Vue.set(this.appearNote, 'myReaction', null); + } + break; + } + + case 'pollVoted': { + const choice = body.choice; + this.appearNote.poll.choices[choice].votes++; + if (body.userId == this.$store.state.i.id) { + Vue.set(this.appearNote.poll.choices[choice], 'isVoted', true); + } + break; + } + + case 'deleted': { + Vue.set(this.appearNote, 'deletedAt', body.deletedAt); + Vue.set(this.appearNote, 'renote', null); + this.appearNote.text = null; + this.appearNote.fileIds = []; + this.appearNote.poll = null; + this.appearNote.cw = null; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(this.$root); + this.$root.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote() { + pleaseLogin(this.$root); + this.blur(); + this.$root.new(MkRenotePicker, { + source: this.$refs.renoteButton, + note: this.appearNote, + }).$once('closed', this.focus); + }, + + renoteDirectly() { + (this as any).$root.api('notes/create', { + renoteId: this.appearNote.id + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(this.$root); + this.blur(); + const picker = this.$root.new(MkReactionPicker, { + source: this.$refs.reactButton, + showFocus: viaKeyboard, + }); + picker.$once('chosen', reaction => { + this.$root.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }).then(() => { + picker.close(); + }); + }); + picker.$once('closed', this.focus); + }, + + reactDirectly(reaction) { + this.$root.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + this.$root.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(this.$root); + this.$root.api('notes/favorites/create', { + noteId: this.appearNote.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }, + + del() { + this.$root.dialog({ + type: 'warning', + text: this.$t('noteDeleteConfirm'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + menu(viaKeyboard = false) { + if (this.openingMenu) return; + this.openingMenu = true; + const w = this.$root.new(MkNoteMenu, { + source: this.$refs.menuButton, + note: this.appearNote, + animation: !viaKeyboard + }).$once('closed', () => { + this.openingMenu = false; + this.focus(); + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focus(this.$el, e => e.previousElementSibling); + }, + + focusAfter() { + focus(this.$el, e => e.nextElementSibling); + } + } +}); +</script> + +<style lang="scss" scoped> +.note { + position: relative; + transition: box-shadow 0.1s ease; + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 14px 16px 9px; + + > .avatar { + margin: 0 10px 8px 0; + width: 50px; + height: 50px; + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .avatar { + width: 44px; + height: 44px; + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px var(--focus); + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > *:first-child { + border-radius: var(--radius) var(--radius) 0 0; + } + + > *:last-child { + border-radius: 0 0 var(--radius) var(--radius); + } + + > .pinned { + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; + + @media (max-width: 450px) { + padding: 8px 16px 0 16px; + } + + > [data-icon] { + margin-right: 4px; + } + } + + > .pinned + .article { + padding-top: 8px; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > [data-icon] { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .mk-time { + flex-shrink: 0; + } + + > .visibility { + margin-left: 8px; + + [data-icon] { + margin-right: 0; + } + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 28px 32px 18px; + + > .avatar { + flex-shrink: 0; + display: block; + //position: sticky; + //top: 72px; + margin: 0 14px 8px 0; + width: 58px; + height: 58px; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } + + > .url-preview { + margin-top: 8px; + } + + > .mk-poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + } + + > .footer { + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--mkykhqkw); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + + > .deleted { + opacity: 0.7; + } + } + } +} +</style> diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue new file mode 100644 index 0000000000..7cf2aa2b02 --- /dev/null +++ b/src/client/components/notes.vue @@ -0,0 +1,144 @@ +<template> +<div class="mk-notes" v-size="[{ max: 500 }]"> + <div class="empty" v-if="empty">{{ $t('noNotes') }}</div> + + <mk-error v-if="error" @retry="init()"/> + + <x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note, i }"> + <x-note :note="note" :detail="detail" :key="note.id" :data-index="i"/> + </x-list> + + <footer v-if="more"> + <button @click="fetchMore()" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" class="_buttonPrimary"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </button> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import XNote from './note.vue'; +import XList from './date-separated-list.vue'; +import getUserName from '../../misc/get-user-name'; +import getNoteSummary from '../../misc/get-note-summary'; + +export default Vue.extend({ + i18n, + + components: { + XNote, XList + }, + + mixins: [ + paging({ + onPrepend: (self, note) => { + // タブが非表示なら通知 + if (document.hidden) { + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(getUserName(note.user), { + body: getNoteSummary(note), + icon: note.user.avatarUrl, + tag: 'newNote' + }); + } + } + }, + + before: (self) => { + self.$emit('before'); + }, + + after: (self, e) => { + self.$emit('after', e); + } + }), + ], + + props: { + pagination: { + required: true + }, + + detail: { + type: Boolean, + required: false, + default: false + }, + + extract: { + required: false + } + }, + + data() { + return { + faSpinner + }; + }, + + computed: { + notes(): any[] { + return this.extract ? this.extract(this.items) : this.items; + }, + }, + + methods: { + focus() { + this.$refs.notes.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-notes { + > .empty { + margin: 0 auto; + padding: 32px; + text-align: center; + background: rgba(0, 0, 0, 0.3); + color: #fff; + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + border-radius: 6px; + } + + > .notes { + > ::v-deep * { + margin-bottom: var(--marginFull); + } + } + + &.max-width_500px { + > .notes { + > ::v-deep * { + margin-bottom: var(--marginHalf); + } + } + } + + > footer { + text-align: center; + + &:empty { + display: none; + } + + > button { + margin: 0; + padding: 16px; + width: 100%; + border-radius: var(--radius); + + &:disabled { + opacity: 0.7; + } + } + } +} +</style> diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue new file mode 100644 index 0000000000..e325f0adb6 --- /dev/null +++ b/src/client/components/notification.vue @@ -0,0 +1,219 @@ +<template> +<div class="mk-notification" :class="notification.type"> + <div class="head"> + <mk-avatar class="avatar" :user="notification.user"/> + <div class="icon" :class="notification.type"> + <fa :icon="faPlus" v-if="notification.type === 'follow'"/> + <fa :icon="faClock" v-if="notification.type === 'receiveFollowRequest'"/> + <fa :icon="faCheck" v-if="notification.type === 'followRequestAccepted'"/> + <fa :icon="faRetweet" v-if="notification.type === 'renote'"/> + <fa :icon="faReply" v-if="notification.type === 'reply'"/> + <fa :icon="faAt" v-if="notification.type === 'mention'"/> + <fa :icon="faQuoteLeft" v-if="notification.type === 'quote'"/> + <x-reaction-icon v-if="notification.type === 'reaction'" :reaction="notification.reaction" :no-style="true"/> + </div> + </div> + <div class="tail"> + <header> + <router-link class="name" :to="notification.user | userPage" v-user-preview="notification.user.id"><mk-user-name :user="notification.user"/></router-link> + <mk-time :time="notification.createdAt" v-if="withTime"/> + </header> + <router-link v-if="notification.type === 'reaction'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <fa :icon="faQuoteLeft"/> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + <fa :icon="faQuoteRight"/> + </router-link> + <router-link v-if="notification.type === 'renote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note.renote)"> + <fa :icon="faQuoteLeft"/> + <mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.renote.emojis"/> + <fa :icon="faQuoteRight"/> + </router-link> + <router-link v-if="notification.type === 'reply'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + </router-link> + <router-link v-if="notification.type === 'mention'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + </router-link> + <router-link v-if="notification.type === 'quote'" class="text" :to="notification.note | notePage" :title="getNoteSummary(notification.note)"> + <mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="nowrap" :custom-emojis="notification.note.emojis"/> + </router-link> + <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $t('youGotNewFollower') }}</span> + <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $t('followRequestAccepted') }}</span> + <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $t('receiveFollowRequest') }}<div v-if="!nowrap && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $t('accept') }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $t('reject') }}</button></div></span> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { faClock } from '@fortawesome/free-regular-svg-icons'; +import getNoteSummary from '../../misc/get-note-summary'; +import XReactionIcon from './reaction-icon.vue'; + +export default Vue.extend({ + components: { + XReactionIcon + }, + props: { + notification: { + type: Object, + required: true, + }, + withTime: { + type: Boolean, + required: false, + default: false, + }, + nowrap: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + getNoteSummary, + followRequestDone: false, + faPlus, faQuoteLeft, faQuoteRight, faRetweet, faReply, faAt, faClock, faCheck + }; + }, + methods: { + acceptFollowRequest() { + this.followRequestDone = true; + this.$root.api('following/requests/accept', { userId: this.notification.user.id }); + }, + rejectFollowRequest() { + this.followRequestDone = true; + this.$root.api('following/requests/reject', { userId: this.notification.user.id }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-notification { + position: relative; + box-sizing: border-box; + padding: 16px; + font-size: 0.9em; + overflow-wrap: break-word; + display: flex; + + @media (max-width: 500px) { + padding: 12px; + font-size: 0.8em; + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > .head { + position: sticky; + top: 0; + flex-shrink: 0; + width: 42px; + height: 42px; + margin-right: 8px; + + > .avatar { + display: block; + width: 100%; + height: 100%; + border-radius: 6px; + } + + > .icon { + position: absolute; + z-index: 1; + bottom: -2px; + right: -2px; + width: 20px; + height: 20px; + box-sizing: border-box; + border-radius: 100%; + background: var(--panel); + box-shadow: 0 0 0 3px var(--panel); + font-size: 12px; + pointer-events: none; + + > * { + color: #fff; + width: 100%; + height: 100%; + } + + &.follow, &.followRequestAccepted, &.receiveFollowRequest { + padding: 3px; + background: #36aed2; + } + + &.retweet { + padding: 3px; + background: #36d298; + } + + &.quote { + padding: 3px; + background: #36d298; + } + + &.reply { + padding: 3px; + background: #007aff; + } + + &.mention { + padding: 3px; + background: #88a6b7; + } + } + } + + > .tail { + flex: 1; + min-width: 0; + + > header { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + overflow: hidden; + } + + > .mk-time { + margin-left: auto; + font-size: 0.9em; + } + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > [data-icon] { + vertical-align: super; + font-size: 50%; + opacity: 0.5; + } + + > [data-icon]:first-child { + margin-right: 4px; + } + + > [data-icon]:last-child { + margin-left: 4px; + } + } + } +} +</style> diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue new file mode 100644 index 0000000000..ad82913380 --- /dev/null +++ b/src/client/components/notifications.vue @@ -0,0 +1,136 @@ +<template> +<div class="mk-notifications"> + <div class="contents"> + <x-list class="notifications" :items="items" v-slot="{ item: notification, i }"> + <x-notification :notification="notification" :with-time="true" :nowrap="false" class="notification" :key="notification.id" :data-index="i"/> + </x-list> + + <button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </button> + + <p class="empty" v-if="empty">{{ $t('noNotifications') }}</p> + + <mk-error v-if="error" @retry="init()"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import XNotification from './notification.vue'; +import XList from './date-separated-list.vue'; + +export default Vue.extend({ + i18n, + + components: { + XNotification, + XList, + }, + + mixins: [ + paging({}), + ], + + props: { + type: { + type: String, + required: false + }, + wide: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + connection: null, + pagination: { + endpoint: 'i/notifications', + limit: 10, + params: () => ({ + includeTypes: this.type ? [this.type] : undefined + }) + }, + faSpinner + }; + }, + + watch: { + type() { + this.reload(); + } + }, + + mounted() { + this.connection = this.$root.stream.useSharedConnection('main'); + this.connection.on('notification', this.onNotification); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.$root.stream.send('readNotification', { + id: notification.id + }); + + this.prepend(notification); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-notifications { + > .contents { + overflow: auto; + height: 100%; + padding: 8px 8px 0 8px; + + > .notifications { + > ::v-deep * { + margin-bottom: 8px; + } + + > .notification { + background: var(--panel); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + } + + > .more { + display: block; + width: 100%; + padding: 16px; + + > [data-icon] { + margin-right: 4px; + } + } + + > .empty { + margin: 0; + padding: 16px; + text-align: center; + color: var(--fg); + } + + > .placeholder { + padding: 32px; + opacity: 0.3; + } + } +} +</style> diff --git a/src/client/components/page-preview.vue b/src/client/components/page-preview.vue new file mode 100644 index 0000000000..5ba226c481 --- /dev/null +++ b/src/client/components/page-preview.vue @@ -0,0 +1,163 @@ +<template> +<router-link :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1"> + <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> + <article> + <header> + <h1 :title="page.title">{{ page.title }}</h1> + </header> + <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> + <footer> + <img class="icon" :src="page.user.avatarUrl"/> + <p>{{ page.user | userName }}</p> + </footer> + </article> +</router-link> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + page: { + type: Object, + required: true + }, + }, +}); +</script> + +<style lang="scss" scoped> +.vhpxefrj { + display: block; + overflow: hidden; + width: 100%; + border: solid var(--lineWidth) var(--urlPreviewBorder); + border-radius: 4px; + overflow: hidden; + + &:hover { + text-decoration: none; + border-color: var(--urlPreviewBorderHover); + } + + > .thumbnail { + position: absolute; + width: 100px; + height: 100%; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + color: var(--urlPreviewTitle); + } + } + + > p { + margin: 0; + color: var(--urlPreviewText); + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + @media (max-width: 700px) { + > .thumbnail { + position: relative; + width: 100%; + height: 100px; + + & + article { + left: 0; + width: 100%; + } + } + } + + @media (max-width: 550px) { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + + @media (max-width: 500px) { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + } +} + +</style> diff --git a/src/client/components/page/page.block.vue b/src/client/components/page/page.block.vue new file mode 100644 index 0000000000..c1d046fa2e --- /dev/null +++ b/src/client/components/page/page.block.vue @@ -0,0 +1,40 @@ +<template> +<component :is="'x-' + value.type" :value="value" :page="page" :script="script" :key="value.id" :h="h"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XText from './page.text.vue'; +import XSection from './page.section.vue'; +import XImage from './page.image.vue'; +import XButton from './page.button.vue'; +import XNumberInput from './page.number-input.vue'; +import XTextInput from './page.text-input.vue'; +import XTextareaInput from './page.textarea-input.vue'; +import XSwitch from './page.switch.vue'; +import XIf from './page.if.vue'; +import XTextarea from './page.textarea.vue'; +import XPost from './page.post.vue'; +import XCounter from './page.counter.vue'; +import XRadioButton from './page.radio-button.vue'; + +export default Vue.extend({ + components: { + XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton + }, + props: { + value: { + required: true + }, + script: { + required: true + }, + page: { + required: true + }, + h: { + required: true + } + }, +}); +</script> diff --git a/src/client/components/page/page.button.vue b/src/client/components/page/page.button.vue new file mode 100644 index 0000000000..eeb56d5eca --- /dev/null +++ b/src/client/components/page/page.button.vue @@ -0,0 +1,59 @@ +<template> +<div> + <mk-button class="kudkigyw" @click="click()" :primary="value.primary">{{ script.interpolate(value.text) }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkButton from '../ui/button.vue'; + +export default Vue.extend({ + components: { + MkButton + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + methods: { + click() { + if (this.value.action === 'dialog') { + this.script.eval(); + this.$root.dialog({ + text: this.script.interpolate(this.value.content) + }); + } else if (this.value.action === 'resetRandom') { + this.script.aiScript.updateRandomSeed(Math.random()); + this.script.eval(); + } else if (this.value.action === 'pushEvent') { + this.$root.api('page-push', { + pageId: this.script.page.id, + event: this.value.event, + ...(this.value.var ? { + var: this.script.vars[this.value.var] + } : {}) + }); + + this.$root.dialog({ + type: 'success', + text: this.script.interpolate(this.value.message) + }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 200px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/src/client/components/page/page.counter.vue b/src/client/components/page/page.counter.vue new file mode 100644 index 0000000000..781a1bd549 --- /dev/null +++ b/src/client/components/page/page.counter.vue @@ -0,0 +1,49 @@ +<template> +<div> + <mk-button class="llumlmnx" @click="click()">{{ script.interpolate(value.text) }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkButton from '../ui/button.vue'; + +export default Vue.extend({ + components: { + MkButton + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + v: 0, + }; + }, + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.eval(); + } + }, + methods: { + click() { + this.v = this.v + (this.value.inc || 1); + } + } +}); +</script> + +<style lang="scss" scoped> +.llumlmnx { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/src/client/components/page/page.if.vue b/src/client/components/page/page.if.vue new file mode 100644 index 0000000000..a714a522e8 --- /dev/null +++ b/src/client/components/page/page.if.vue @@ -0,0 +1,29 @@ +<template> +<div v-show="script.vars[value.var]"> + <x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + }, + page: { + required: true + }, + h: { + required: true + } + }, + beforeCreate() { + this.$options.components.XBlock = require('./page.block.vue').default; + }, +}); +</script> diff --git a/src/client/components/page/page.image.vue b/src/client/components/page/page.image.vue new file mode 100644 index 0000000000..f0d7c7b30f --- /dev/null +++ b/src/client/components/page/page.image.vue @@ -0,0 +1,36 @@ +<template> +<div class="lzyxtsnt"> + <img v-if="image" :src="image.url" alt=""/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + page: { + required: true + }, + }, + data() { + return { + image: null, + }; + }, + created() { + this.image = this.page.attachedFiles.find(x => x.id === this.value.fileId); + } +}); +</script> + +<style lang="scss" scoped> +.lzyxtsnt { + > img { + max-width: 100%; + } +} +</style> diff --git a/src/client/components/page/page.number-input.vue b/src/client/components/page/page.number-input.vue new file mode 100644 index 0000000000..9ee2730fac --- /dev/null +++ b/src/client/components/page/page.number-input.vue @@ -0,0 +1,44 @@ +<template> +<div> + <mk-input class="kudkigyw" v-model="v" type="number">{{ script.interpolate(value.text) }}</mk-input> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkInput from '../ui/input.vue'; + +export default Vue.extend({ + components: { + MkInput + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + v: this.value.default, + }; + }, + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.eval(); + } + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/src/client/components/page/page.post.vue b/src/client/components/page/page.post.vue new file mode 100644 index 0000000000..010a96c855 --- /dev/null +++ b/src/client/components/page/page.post.vue @@ -0,0 +1,75 @@ +<template> +<div class="ngbfujlo"> + <mk-textarea class="textarea" :value="text" readonly></mk-textarea> + <mk-button primary @click="post()" :disabled="posting || posted">{{ posted ? $t('posted-from-post-form') : $t('post-from-post-form') }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../i18n'; +import MkTextarea from '../ui/textarea.vue'; +import MkButton from '../ui/button.vue'; + +export default Vue.extend({ + i18n, + components: { + MkTextarea, + MkButton, + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + text: this.script.interpolate(this.value.text), + posted: false, + posting: false, + }; + }, + watch: { + 'script.vars': { + handler() { + this.text = this.script.interpolate(this.value.text); + }, + deep: true + } + }, + methods: { + post() { + this.posting = true; + this.$root.api('notes/create', { + text: this.text, + }).then(() => { + this.posted = true; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.ngbfujlo { + padding: 0 32px 32px 32px; + border: solid 2px var(--divider); + border-radius: 6px; + + @media (max-width: 600px) { + padding: 0 16px 16px 16px; + + > .textarea { + margin-top: 16px; + margin-bottom: 16px; + } + } +} +</style> diff --git a/src/client/components/page/page.radio-button.vue b/src/client/components/page/page.radio-button.vue new file mode 100644 index 0000000000..fda0a03927 --- /dev/null +++ b/src/client/components/page/page.radio-button.vue @@ -0,0 +1,36 @@ +<template> +<div> + <div>{{ script.interpolate(value.title) }}</div> + <mk-radio v-for="x in value.values" v-model="v" :value="x" :key="x">{{ x }}</mk-radio> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkRadio from '../ui/radio.vue'; + +export default Vue.extend({ + components: { + MkRadio + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + v: this.value.default, + }; + }, + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.eval(); + } + } +}); +</script> diff --git a/src/client/components/page/page.section.vue b/src/client/components/page/page.section.vue new file mode 100644 index 0000000000..b83c773f71 --- /dev/null +++ b/src/client/components/page/page.section.vue @@ -0,0 +1,58 @@ +<template> +<section class="sdgxphyu"> + <component :is="'h' + h">{{ value.title }}</component> + + <div class="children"> + <x-block v-for="child in value.children" :value="child" :page="page" :script="script" :key="child.id" :h="h + 1"/> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + }, + page: { + required: true + }, + h: { + required: true + } + }, + beforeCreate() { + this.$options.components.XBlock = require('./page.block.vue').default; + }, +}); +</script> + +<style lang="scss" scoped> +.sdgxphyu { + margin: 1.5em 0; + + > h2 { + font-size: 1.35em; + margin: 0 0 0.5em 0; + } + + > h3 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > h4 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > .children { + //padding 16px + } +} +</style> diff --git a/src/client/components/page/page.switch.vue b/src/client/components/page/page.switch.vue new file mode 100644 index 0000000000..416c36e9ad --- /dev/null +++ b/src/client/components/page/page.switch.vue @@ -0,0 +1,46 @@ +<template> +<div class="hkcxmtwj"> + <mk-switch v-model="v">{{ script.interpolate(value.text) }}</mk-switch> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkSwitch from '../ui/switch.vue'; + +export default Vue.extend({ + components: { + MkSwitch + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + v: this.value.default, + }; + }, + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.eval(); + } + } +}); +</script> + +<style lang="scss" scoped> +.hkcxmtwj { + display: inline-block; + margin: 16px auto; + + & + .hkcxmtwj { + margin-left: 16px; + } +} +</style> diff --git a/src/client/components/page/page.text-input.vue b/src/client/components/page/page.text-input.vue new file mode 100644 index 0000000000..fcc181d673 --- /dev/null +++ b/src/client/components/page/page.text-input.vue @@ -0,0 +1,44 @@ +<template> +<div> + <mk-input class="kudkigyw" v-model="v" type="text">{{ script.interpolate(value.text) }}</mk-input> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkInput from '../ui/input.vue'; + +export default Vue.extend({ + components: { + MkInput + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + v: this.value.default, + }; + }, + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.eval(); + } + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/src/client/components/page/page.text.vue b/src/client/components/page/page.text.vue new file mode 100644 index 0000000000..aeab31225e --- /dev/null +++ b/src/client/components/page/page.text.vue @@ -0,0 +1,65 @@ +<template> +<div class="mrdgzndn"> + <mfm :text="text" :is-note="false" :i="$store.state.i" :key="text"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url" class="url"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { parse } from '../../../mfm/parse'; +import { unique } from '../../../prelude/array'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + text: this.script.interpolate(this.value.text), + }; + }, + computed: { + urls(): string[] { + if (this.text) { + const ast = parse(this.text); + // TODO: 再帰的にURL要素がないか調べる + return unique(ast + .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent)) + .map(t => t.node.props.url)); + } else { + return []; + } + } + }, + watch: { + 'script.vars': { + handler() { + this.text = this.script.interpolate(this.value.text); + }, + deep: true + } + }, +}); +</script> + +<style lang="scss" scoped> +.mrdgzndn { + &:not(:first-child) { + margin-top: 0.5em; + } + + &:not(:last-child) { + margin-bottom: 0.5em; + } + + > .url { + margin: 0.5em 0; + } +} +</style> diff --git a/src/client/components/page/page.textarea-input.vue b/src/client/components/page/page.textarea-input.vue new file mode 100644 index 0000000000..d1cf9813c4 --- /dev/null +++ b/src/client/components/page/page.textarea-input.vue @@ -0,0 +1,35 @@ +<template> +<div> + <mk-textarea v-model="v">{{ script.interpolate(value.text) }}</mk-textarea> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkTextarea from '../ui/textarea.vue'; + +export default Vue.extend({ + components: { + MkTextarea + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + v: this.value.default, + }; + }, + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.eval(); + } + } +}); +</script> diff --git a/src/client/components/page/page.textarea.vue b/src/client/components/page/page.textarea.vue new file mode 100644 index 0000000000..78b74dd64c --- /dev/null +++ b/src/client/components/page/page.textarea.vue @@ -0,0 +1,35 @@ +<template> +<mk-textarea :value="text" readonly></mk-textarea> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkTextarea from '../ui/textarea.vue'; + +export default Vue.extend({ + components: { + MkTextarea + }, + props: { + value: { + required: true + }, + script: { + required: true + } + }, + data() { + return { + text: this.script.interpolate(this.value.text), + }; + }, + watch: { + 'script.vars': { + handler() { + this.text = this.script.interpolate(this.value.text); + }, + deep: true + } + } +}); +</script> diff --git a/src/client/components/page/page.vue b/src/client/components/page/page.vue new file mode 100644 index 0000000000..bd78313475 --- /dev/null +++ b/src/client/components/page/page.vue @@ -0,0 +1,230 @@ +<template> +<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }"> + <header v-if="showTitle"> + <div class="title">{{ page.title }}</div> + </header> + + <div v-if="script"> + <x-block v-for="child in page.content" :value="child" @input="v => updateBlock(v)" :page="page" :script="script" :key="child.id" :h="2"/> + </div> + + <footer v-if="showFooter"> + <small>@{{ page.user.username }}</small> + <template v-if="$store.getters.isSignedIn && $store.state.i.id === page.userId"> + <router-link :to="`/my/pages/edit/${page.id}`">{{ $t('edit-this-page') }}</router-link> + <a v-if="$store.state.i.pinnedPageId === page.id" @click="pin(false)">{{ $t('unpin-this-page') }}</a> + <a v-else @click="pin(true)">{{ $t('pin-this-page') }}</a> + </template> + <router-link :to="`./${page.name}/view-source`">{{ $t('view-source') }}</router-link> + <div class="like"> + <button @click="unlike()" v-if="page.isLiked" :title="$t('unlike')"><fa :icon="faHeartS"/></button> + <button @click="like()" v-else :title="$t('like')"><fa :icon="faHeart"/></button> + <span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span> + </div> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../i18n'; +import { faHeart as faHeartS } from '@fortawesome/free-solid-svg-icons'; +import { faHeart } from '@fortawesome/free-regular-svg-icons'; +import XBlock from './page.block.vue'; +import { ASEvaluator } from '../../scripts/aiscript/evaluator'; +import { collectPageVars } from '../../scripts/collect-page-vars'; +import { url } from '../../config'; + +class Script { + public aiScript: ASEvaluator; + private onError: any; + public vars: Record<string, any>; + public page: Record<string, any>; + + constructor(page, aiScript, onError) { + this.page = page; + this.aiScript = aiScript; + this.onError = onError; + this.eval(); + } + + public eval() { + try { + this.vars = this.aiScript.evaluateVars(); + } catch (e) { + this.onError(e); + } + } + + public interpolate(str: string) { + if (str == null) return null; + return str.replace(/{(.+?)}/g, match => { + const v = this.vars[match.slice(1, -1).trim()]; + return v == null ? 'NULL' : v.toString(); + }); + } +} + +export default Vue.extend({ + i18n, + + components: { + XBlock + }, + + props: { + page: { + type: Object, + required: true + }, + showTitle: { + type: Boolean, + required: false, + default: true + }, + showFooter: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + return { + script: null, + faHeartS, faHeart + }; + }, + + created() { + const pageVars = this.getPageVars(); + this.script = new Script(this.page, new ASEvaluator(this.page.variables, pageVars, { + randomSeed: Math.random(), + user: this.page.user, + visitor: this.$store.state.i, + page: this.page, + url: url + }), e => { + console.dir(e); + }); + }, + + methods: { + getPageVars() { + return collectPageVars(this.page.content); + }, + + like() { + this.$root.api('pages/like', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = true; + this.page.likedCount++; + }); + }, + + unlike() { + this.$root.api('pages/unlike', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = false; + this.page.likedCount--; + }); + }, + + pin(pin) { + this.$root.api('i/update', { + pinnedPageId: pin ? this.page.id : null, + }).then(() => { + this.$root.dialog({ + type: 'success', + splash: true + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.iroscrza { + &.serif { + > div { + font-family: serif; + } + } + + &.center { + text-align: center; + } + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 16px 32px; + font-size: 20px; + font-weight: bold; + color: var(--text); + box-shadow: 0 var(--lineWidth) rgba(#000, 0.07); + + @media (max-width: 600px) { + padding: 16px 32px; + font-size: 20px; + } + + @media (max-width: 400px) { + padding: 10px 20px; + font-size: 16px; + } + } + } + + > div { + color: var(--text); + padding: 24px 32px; + font-size: 16px; + + @media (max-width: 600px) { + padding: 24px 32px; + font-size: 16px; + } + + @media (max-width: 400px) { + padding: 20px 20px; + font-size: 15px; + } + } + + > footer { + color: var(--text); + padding: 0 32px 28px 32px; + + @media (max-width: 600px) { + padding: 0 32px 28px 32px; + } + + @media (max-width: 400px) { + padding: 0 20px 20px 20px; + font-size: 14px; + } + + > small { + display: block; + opacity: 0.5; + } + + > a { + font-size: 90%; + } + + > a + a { + margin-left: 8px; + } + + > .like { + margin-top: 16px; + } + } +} +</style> diff --git a/src/client/components/poll-editor.vue b/src/client/components/poll-editor.vue new file mode 100644 index 0000000000..b5b8c2c02d --- /dev/null +++ b/src/client/components/poll-editor.vue @@ -0,0 +1,218 @@ +<template> +<div class="zmdxowus"> + <p class="caution" v-if="choices.length < 2"> + <fa :icon="faExclamationTriangle"/>{{ $t('_poll.noOnlyOneChoice') }} + </p> + <ul ref="choices"> + <li v-for="(choice, i) in choices" :key="i"> + <mk-input class="input" :value="choice" @input="onInput(i, $event)"> + <span>{{ $t('_poll.choiceN', { n: i + 1 }) }}</span> + </mk-input> + <button @click="remove(i)" class="_button"> + <fa :icon="faTimes"/> + </button> + </li> + </ul> + <mk-button class="add" v-if="choices.length < 10" @click="add">{{ $t('add') }}</mk-button> + <mk-button class="add" v-else disabled>{{ $t('_poll.noMore') }}</mk-button> + <section> + <mk-switch v-model="multiple">{{ $t('_poll.canMultipleVote') }}</mk-switch> + <div> + <mk-select v-model="expiration"> + <template #label>{{ $t('_poll.expiration') }}</template> + <option value="infinite">{{ $t('_poll.infinite') }}</option> + <option value="at">{{ $t('_poll.at') }}</option> + <option value="after">{{ $t('_poll.after') }}</option> + </mk-select> + <section v-if="expiration === 'at'"> + <mk-input v-model="atDate" type="date" class="input"> + <span>{{ $t('_poll.deadlineDate') }}</span> + </mk-input> + <mk-input v-model="atTime" type="time" class="input"> + <span>{{ $t('_poll.deadlineTime') }}</span> + </mk-input> + </section> + <section v-if="expiration === 'after'"> + <mk-input v-model="after" type="number" class="input"> + <span>{{ $t('_poll.duration') }}</span> + </mk-input> + <mk-select v-model="unit"> + <option value="second">{{ $t('_time.second') }}</option> + <option value="minute">{{ $t('_time.minute') }}</option> + <option value="hour">{{ $t('_time.hour') }}</option> + <option value="day">{{ $t('_time.day') }}</option> + </mk-select> + </section> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import { erase } from '../../prelude/array'; +import { addTimespan } from '../../prelude/time'; +import { formatDateTimeString } from '../../misc/format-time-string'; +import MkInput from './ui/input.vue'; +import MkSelect from './ui/select.vue'; +import MkSwitch from './ui/switch.vue'; +import MkButton from './ui/button.vue'; + +export default Vue.extend({ + i18n, + components: { + MkInput, + MkSelect, + MkSwitch, + MkButton, + }, + data() { + return { + choices: ['', ''], + multiple: false, + expiration: 'infinite', + atDate: formatDateTimeString(addTimespan(new Date(), 1, 'days'), 'yyyy-MM-dd'), + atTime: '00:00', + after: 0, + unit: 'second', + faExclamationTriangle, faTimes + }; + }, + watch: { + choices() { + this.$emit('updated'); + } + }, + methods: { + onInput(i, e) { + Vue.set(this.choices, i, e); + }, + + add() { + this.choices.push(''); + this.$nextTick(() => { + (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + }); + }, + + remove(i) { + this.choices = this.choices.filter((_, _i) => _i != i); + }, + + get() { + const at = () => { + return new Date(`${this.atDate} ${this.atTime}`).getTime(); + }; + + const after = () => { + let base = parseInt(this.after); + switch (this.unit) { + case 'day': base *= 24; + case 'hour': base *= 60; + case 'minute': base *= 60; + case 'second': return base *= 1000; + default: return null; + } + }; + + return { + choices: erase('', this.choices), + multiple: this.multiple, + ...( + this.expiration === 'at' ? { expiresAt: at() } : + this.expiration === 'after' ? { expiredAfter: after() } : {}) + }; + }, + + set(data) { + if (data.choices.length == 0) return; + this.choices = data.choices; + if (data.choices.length == 1) this.choices = this.choices.concat(''); + this.multiple = data.multiple; + if (data.expiresAt) { + this.expiration = 'at'; + this.atDate = this.atTime = data.expiresAt; + } else if (typeof data.expiredAfter === 'number') { + this.expiration = 'after'; + this.after = data.expiredAfter; + } else { + this.expiration = 'infinite'; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.zmdxowus { + padding: 8px; + + > .caution { + margin: 0 0 8px 0; + font-size: 0.8em; + color: #f00; + + > [data-icon] { + margin-right: 4px; + } + } + + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: flex; + margin: 8px 0; + padding: 0; + width: 100%; + + > .input { + flex: 1; + margin-top: 16px; + margin-bottom: 0; + } + + > button { + width: 32px; + padding: 4px 0; + } + } + } + + > .add { + margin: 8px 0 0 0; + z-index: 1; + } + + > section { + margin: 16px 0 -16px 0; + + > div { + margin: 0 8px; + + &:last-child { + flex: 1 0 auto; + + > section { + align-items: center; + display: flex; + margin: -32px 0 0; + + > &:first-child { + margin-right: 16px; + } + + > .input { + flex: 1 0 auto; + } + } + } + } + } +} +</style> diff --git a/src/client/components/poll.vue b/src/client/components/poll.vue new file mode 100644 index 0000000000..15be1b282d --- /dev/null +++ b/src/client/components/poll.vue @@ -0,0 +1,174 @@ +<template> +<div class="mk-poll" :data-done="closed || isVoted"> + <ul> + <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }"> + <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> + <span> + <template v-if="choice.isVoted"><fa :icon="faCheck"/></template> + <mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> + <span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> + </span> + </li> + </ul> + <p> + <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> + <span> · </span> + <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $t('_poll.vote') : $t('_poll.showResult') }}</a> + <span v-if="isVoted">{{ $t('_poll.voted') }}</span> + <span v-else-if="closed">{{ $t('_poll.closed') }}</span> + <span v-if="remaining > 0"> · {{ timer }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import { sum } from '../../prelude/array'; + +export default Vue.extend({ + i18n, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + remaining: -1, + showResult: false, + faCheck + }; + }, + computed: { + poll(): any { + return this.note.poll; + }, + total(): number { + return sum(this.poll.choices.map(x => x.votes)); + }, + closed(): boolean { + return !this.remaining; + }, + timer(): string { + return this.$t( + this.remaining > 86400 ? '_poll.remainingDays' : + this.remaining > 3600 ? '_poll.remainingHours' : + this.remaining > 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { + s: Math.floor(this.remaining % 60), + m: Math.floor(this.remaining / 60) % 60, + h: Math.floor(this.remaining / 3600) % 24, + d: Math.floor(this.remaining / 86400) + }); + }, + isVoted(): boolean { + return !this.poll.multiple && this.poll.choices.some(c => c.isVoted); + } + }, + created() { + this.showResult = this.isVoted; + + if (this.note.poll.expiresAt) { + const update = () => { + if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000)) + requestAnimationFrame(update); + else + this.showResult = true; + }; + + update(); + } + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; + this.$root.api('notes/polls/vote', { + noteId: this.note.id, + choice: id + }).then(() => { + if (!this.showResult) this.showResult = !this.poll.multiple; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-poll { + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: block; + position: relative; + margin: 4px 0; + padding: 4px 8px; + width: 100%; + color: var(--pollChoiceText); + border: solid 1px var(--pollChoiceBorder); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + + &:hover { + background: rgba(#000, 0.05); + } + + &:active { + background: rgba(#000, 0.1); + } + + > .backdrop { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + transition: width 1s ease; + } + + > span { + position: relative; + + > [data-icon] { + margin-right: 4px; + } + + > .votes { + margin-left: 4px; + } + } + } + } + + > p { + color: var(--fg); + + a { + color: inherit; + } + } + + &[data-done] { + > ul > li { + cursor: default; + + &:hover { + background: transparent; + } + + &:active { + background: transparent; + } + } + } +} +</style> diff --git a/src/client/components/popup.vue b/src/client/components/popup.vue new file mode 100644 index 0000000000..d5b1f9423b --- /dev/null +++ b/src/client/components/popup.vue @@ -0,0 +1,147 @@ +<template> +<div class="mk-popup"> + <transition name="bg-fade" appear> + <div class="bg" ref="bg" @click="close()" v-if="show"></div> + </transition> + <transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> + <div class="content" :class="{ fixed }" ref="content" v-if="show" :style="{ width: width ? width + 'px' : 'auto' }"><slot></slot></div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + source: { + required: true + }, + noCenter: { + type: Boolean, + required: false + }, + fixed: { + type: Boolean, + required: false + }, + width: { + type: Number, + required: false + } + }, + data() { + return { + show: true, + }; + }, + mounted() { + this.$nextTick(() => { + const popover = this.$refs.content as any; + + const rect = this.source.getBoundingClientRect(); + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.$root.isMobile && !this.noCenter) { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.source.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + popover.style.transformOrigin = 'center'; + } else { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.source.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.source.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (this.fixed) { + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + popover.style.transformOrigin = 'center'; + } + + if (top + height > window.innerHeight) { + top = window.innerHeight - height; + popover.style.transformOrigin = 'center'; + } + } else { + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset; + popover.style.transformOrigin = 'center'; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset; + popover.style.transformOrigin = 'center'; + } + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + }); + }, + methods: { + close() { + this.show = false; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.content as any).style.pointerEvents = 'none'; + } + } +}); +</script> + +<style lang="scss" scoped> +.popup-enter-active, .popup-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.popup-enter, .popup-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.bg-fade-enter-active, .bg-fade-leave-active { + transition: opacity 0.3s !important; +} +.bg-fade-enter, .bg-fade-leave-to { + opacity: 0; +} + +.mk-popup { + > .bg { + position: fixed; + top: 0; + left: 0; + z-index: 10000; + width: 100%; + height: 100%; + background: var(--modalBg) + } + + > .content { + position: absolute; + z-index: 10001; + background: var(--panel); + border-radius: 4px; + box-shadow: 0 3px 12px rgba(27, 31, 35, 0.15); + overflow: hidden; + transform-origin: center top; + + &.fixed { + position: fixed; + } + } +} +</style> diff --git a/src/client/components/post-form-attaches.vue b/src/client/components/post-form-attaches.vue new file mode 100644 index 0000000000..50ba9bfdcf --- /dev/null +++ b/src/client/components/post-form-attaches.vue @@ -0,0 +1,158 @@ +<template> +<div class="skeikyzd" v-show="files.length != 0"> + <x-draggable class="files" :list="files" animation="150"> + <div v-for="file in files" :key="file.id" @click="showFileMenu(file, $event)" @contextmenu.prevent="showFileMenu(file, $event)"> + <x-file-thumbnail :data-id="file.id" class="thumbnail" :file="file" fit="cover"/> + <div class="sensitive" v-if="file.isSensitive"> + <fa class="icon" :icon="faExclamationTriangle"/> + </div> + </div> + </x-draggable> + <p class="remain">{{ 4 - files.length }}/4</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import * as XDraggable from 'vuedraggable'; +import { faTimesCircle, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; +import { faExclamationTriangle, faICursor } from '@fortawesome/free-solid-svg-icons'; +import XFileThumbnail from './drive-file-thumbnail.vue' + +export default Vue.extend({ + i18n, + + components: { + XDraggable, + XFileThumbnail + }, + + props: { + files: { + type: Array, + required: true + }, + detachMediaFn: { + type: Function, + required: false + } + }, + + data() { + return { + faExclamationTriangle + }; + }, + + methods: { + detachMedia(id) { + if (this.detachMediaFn) { + this.detachMediaFn(id); + } else if (this.$parent.detachMedia) { + this.$parent.detachMedia(id); + } + }, + toggleSensitive(file) { + this.$root.api('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive + }).then(() => { + file.isSensitive = !file.isSensitive; + this.$parent.updateMedia(file); + }); + }, + async rename(file) { + const { canceled, result } = await this.$root.dialog({ + title: this.$t('enterFileName'), + input: { + default: file.name + }, + allowEmpty: false + }); + if (canceled) return; + this.$root.api('drive/files/update', { + fileId: file.id, + name: result + }).then(() => { + file.name = result; + this.$parent.updateMedia(file); + }); + }, + showFileMenu(file, ev: MouseEvent) { + this.$root.menu({ + items: [{ + text: this.$t('renameFile'), + icon: faICursor, + action: () => { this.rename(file) } + }, { + text: file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'), + icon: file.isSensitive ? faEyeSlash : faEye, + action: () => { this.toggleSensitive(file) } + }, { + text: this.$t('attachCancel'), + icon: faTimesCircle, + action: () => { this.detachMedia(file.id) } + }], + source: ev.currentTarget || ev.target + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.skeikyzd { + padding: 4px; + position: relative; + + > .files { + display: flex; + flex-wrap: wrap; + + > div { + position: relative; + width: 64px; + height: 64px; + margin: 4px; + cursor: move; + + &:hover > .remove { + display: block; + } + + > .thumbnail { + width: 100%; + height: 100%; + z-index: 1; + color: var(--fg); + } + + > .sensitive { + display: flex; + position: absolute; + width: 64px; + height: 64px; + top: 0; + left: 0; + z-index: 2; + background: rgba(17, 17, 17, .7); + color: #fff; + + > .icon { + margin: auto; + } + } + } + } + + > .remain { + display: block; + position: absolute; + top: 8px; + right: 8px; + margin: 0; + padding: 0; + } +} +</style> diff --git a/src/client/components/post-form-dialog.vue b/src/client/components/post-form-dialog.vue new file mode 100644 index 0000000000..fe70b88218 --- /dev/null +++ b/src/client/components/post-form-dialog.vue @@ -0,0 +1,157 @@ +<template> +<div class="ulveipglmagnxfgvitaxyszerjwiqmwl"> + <transition name="form-fade" appear> + <div class="bg" ref="bg" v-if="show" @click="close()"></div> + </transition> + <div class="main" ref="main" @click.self="close()" @keydown="onKeydown"> + <transition name="form" appear + @after-leave="destroyDom" + > + <x-post-form ref="form" + v-if="show" + :reply="reply" + :renote="renote" + :mention="mention" + :specified="specified" + :initial-text="initialText" + :initial-note="initialNote" + :instant="instant" + @posted="onPosted" + @cancel="onCanceled"/> + </transition> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPostForm from './post-form.vue'; + +export default Vue.extend({ + components: { + XPostForm + }, + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialNote: { + type: Object, + required: false + }, + instant: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + show: true + }; + }, + + methods: { + focus() { + this.$refs.form.focus(); + }, + + close() { + this.show = false; + (this.$refs.bg as any).style.pointerEvents = 'none'; + (this.$refs.main as any).style.pointerEvents = 'none'; + }, + + onPosted() { + this.$emit('posted'); + this.close(); + }, + + onCanceled() { + this.$emit('cancel'); + this.close(); + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.form-enter-active, .form-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.form-enter, .form-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.form-fade-enter-active, .form-fade-leave-active { + transition: opacity 0.3s !important; +} +.form-fade-enter, .form-fade-leave-to { + opacity: 0; +} + +.ulveipglmagnxfgvitaxyszerjwiqmwl { + > .bg { + display: block; + position: fixed; + z-index: 10000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(#000, 0.7); + } + + > .main { + display: block; + position: fixed; + z-index: 10000; + top: 32px; + left: 0; + right: 0; + height: calc(100% - 64px); + width: 500px; + max-width: calc(100% - 16px); + overflow: auto; + margin: 0 auto 0 auto; + + @media (max-width: 550px) { + top: 16px; + height: calc(100% - 32px); + } + + @media (max-width: 520px) { + top: 8px; + height: calc(100% - 16px); + } + } +} +</style> diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue new file mode 100644 index 0000000000..762b82036b --- /dev/null +++ b/src/client/components/post-form.vue @@ -0,0 +1,747 @@ +<template> +<div class="gafaadew" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <header> + <button class="cancel _button" @click="cancel"><fa :icon="faTimes"/></button> + <div> + <span class="text-count" :class="{ over: trimmedLength(text) > 500 }">{{ 500 - trimmedLength(text) }}</span> + <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}</button> + </div> + </header> + <div class="form"> + <x-note-preview class="preview" v-if="reply" :note="reply"/> + <x-note-preview class="preview" v-if="renote" :note="renote"/> + <div class="with-quote" v-if="quoteId"><fa icon="quote-left"/> {{ $t('@.post-form.quote-attached') }}<button @click="quoteId = null"><fa icon="times"/></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ $t('recipient') }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers"> + <mk-acct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><fa :icon="faTimes"/></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><fa :icon="faPlus" fixed-width/></button> + </div> + </div> + <input v-show="useCw" ref="cw" v-model="cw" :placeholder="$t('annotation')" v-autocomplete="{ model: 'cw' }"> + <textarea v-model="text" ref="text" :disabled="posting" :placeholder="placeholder" v-autocomplete="{ model: 'text' }" @keydown="onKeydown" @paste="onPaste"></textarea> + <x-post-form-attaches class="attaches" :files="files"/> + <x-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="onPollUpdate()"/> + <x-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> + <footer> + <button class="_button" @click="chooseFileFrom"><fa :icon="faPhotoVideo"/></button> + <button class="_button" @click="poll = !poll"><fa :icon="faChartPie"/></button> + <button class="_button" @click="useCw = !useCw"><fa :icon="faEyeSlash"/></button> + <button class="_button" @click="insertMention"><fa :icon="faAt"/></button> + <button class="_button" @click="insertEmoji"><fa :icon="faLaughSquint"/></button> + <button class="_button" @click="setVisibility" ref="visibilityButton"> + <span v-if="visibility === 'public'"><fa :icon="faGlobe"/></span> + <span v-if="visibility === 'home'"><fa :icon="faHome"/></span> + <span v-if="visibility === 'followers'"><fa :icon="faUnlock"/></span> + <span v-if="visibility === 'specified'"><fa :icon="faEnvelope"/></span> + </button> + </footer> + <input ref="file" class="file _button" type="file" multiple="multiple" @change="onChangeFile"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faCloud, faLink, faAt } from '@fortawesome/free-solid-svg-icons'; +import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode'; +import i18n from '../i18n'; +import MkVisibilityChooser from './visibility-chooser.vue'; +import MkUserSelect from './user-select.vue'; +import XNotePreview from './note-preview.vue'; +import XEmojiPicker from './emoji-picker.vue'; +import { parse } from '../../mfm/parse'; +import { host, url } from '../config'; +import { erase, unique } from '../../prelude/array'; +import extractMentions from '../../misc/extract-mentions'; +import getAcct from '../../misc/acct/render'; +import { formatTimeString } from '../../misc/format-time-string'; +import { selectDriveFile } from '../scripts/select-drive-file'; + +export default Vue.extend({ + i18n, + + components: { + XNotePreview, + XUploader: () => import('./uploader.vue').then(m => m.default), + XPostFormAttaches: () => import('./post-form-attaches.vue').then(m => m.default), + XPollEditor: () => import('./poll-editor.vue').then(m => m.default) + }, + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialNote: { + type: Object, + required: false + }, + instant: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + posting: false, + text: '', + files: [], + uploadings: [], + poll: false, + pollChoices: [], + pollMultiple: false, + pollExpiration: [], + useCw: false, + cw: null, + visibility: 'public', + visibleUsers: [], + autocomplete: null, + draghover: false, + quoteId: null, + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + faTimes, faUpload, faChartPie, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faCloud, faLink, faAt + }; + }, + + computed: { + draftId(): string { + return this.renote + ? `renote:${this.renote.id}` + : this.reply + ? `reply:${this.reply.id}` + : 'note'; + }, + + placeholder(): string { + const xs = [ + this.$t('_postForm._placeholders.a'), + this.$t('_postForm._placeholders.b'), + this.$t('_postForm._placeholders.c'), + this.$t('_postForm._placeholders.d'), + this.$t('_postForm._placeholders.e'), + this.$t('_postForm._placeholders.f') + ]; + const x = xs[Math.floor(Math.random() * xs.length)]; + + return this.renote + ? this.$t('_postForm.quotePlaceholder') + : this.reply + ? this.$t('_postForm.replyPlaceholder') + : x; + }, + + submitText(): string { + return this.renote + ? this.$t('renote') + : this.reply + ? this.$t('reply') + : this.$t('_postForm.post'); + }, + + canPost(): boolean { + return !this.posting && + (1 <= this.text.length || 1 <= this.files.length || this.poll || this.renote) && + (length(this.text.trim()) <= 500) && + (!this.poll || this.pollChoices.length >= 2); + } + }, + + mounted() { + if (this.initialText) { + this.text = this.initialText; + } + + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; + } + + if (this.reply && this.reply.text != null) { + const ast = parse(this.reply.text); + + for (const x of extractMentions(ast)) { + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; + + // 自分は除外 + if (this.$store.state.i.username == x.username && x.host == null) continue; + if (this.$store.state.i.username == x.username && x.host == host) continue; + + // 重複は除外 + if (this.text.indexOf(`${mention} `) != -1) continue; + + this.text += `${mention} `; + } + } + + // デフォルト公開範囲 + this.applyVisibility(this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : this.$store.state.settings.defaultNoteVisibility); + + // 公開以外へのリプライ時は元の公開範囲を引き継ぐ + if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { + this.visibility = this.reply.visibility; + if (this.reply.visibility === 'specified') { + this.$root.api('users/show', { + userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$store.state.i.id && uid !== this.reply.userId) + }).then(users => { + this.visibleUsers.push(...users); + }); + + if (this.reply.userId !== this.$store.state.i.id) { + this.$root.api('users/show', { userId: this.reply.userId }).then(user => { + this.visibleUsers.push(user); + }); + } + } + } + + if (this.specified) { + this.visibility = 'specified'; + this.visibleUsers.push(this.specified); + } + + // keep cw when reply + if (this.$store.state.settings.keepCw && this.reply && this.reply.cw) { + this.useCw = true; + this.cw = this.reply.cw; + } + + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + + this.$nextTick(() => { + // 書きかけの投稿を復元 + if (!this.instant && !this.mention) { + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; + if (draft) { + this.text = draft.data.text; + this.files = (draft.data.files || []).filter(e => e); + if (draft.data.poll) { + this.poll = true; + this.$nextTick(() => { + (this.$refs.poll as any).set(draft.data.poll); + }); + } + this.$emit('change-attached-files', this.files); + } + } + + // 削除して編集 + if (this.initialNote) { + const init = this.initialNote; + this.text = init.text ? init.text : ''; + this.files = init.files; + this.cw = init.cw; + this.useCw = init.cw != null; + if (init.poll) { + this.poll = true; + this.$nextTick(() => { + (this.$refs.poll as any).set({ + choices: init.poll.choices.map(c => c.text), + multiple: init.poll.multiple + }); + }); + } + this.visibility = init.visibility; + this.quoteId = init.renote ? init.renote.id : null; + } + + this.$nextTick(() => this.watch()); + }); + }, + + methods: { + watch() { + this.$watch('text', () => this.saveDraft()); + this.$watch('poll', () => this.saveDraft()); + this.$watch('files', () => this.saveDraft()); + }, + + trimmedLength(text: string) { + return length(text.trim()); + }, + + addTag(tag: string) { + insertTextAtCursor(this.$refs.text, ` #${tag} `); + }, + + focus() { + (this.$refs.text as any).focus(); + }, + + chooseFileFrom(ev) { + this.$root.menu({ + items: [{ + type: 'label', + text: this.$t('attachFile'), + }, { + text: this.$t('upload'), + icon: faUpload, + action: () => { this.chooseFileFromPc() } + }, { + text: this.$t('fromDrive'), + icon: faCloud, + action: () => { this.chooseFileFromDrive() } + }, { + text: this.$t('fromUrl'), + icon: faLink, + action: () => { this.chooseFileFromUrl() } + }], + source: ev.currentTarget || ev.target + }); + }, + + chooseFileFromPc() { + (this.$refs.file as any).click(); + }, + + chooseFileFromDrive() { + selectDriveFile(this.$root, true).then(files => { + for (const file of files) { + this.attachMedia(file); + } + }); + }, + + attachMedia(driveFile) { + this.files.push(driveFile); + }, + + detachMedia(id) { + this.files = this.files.filter(x => x.id != id); + }, + + updateMedia(file) { + Vue.set(this.files, this.files.findIndex(x => x.id === file.id), file); + }, + + onChangeFile() { + for (const x of Array.from((this.$refs.file as any).files)) this.upload(x); + }, + + upload(file: File, name?: string) { + (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name); + }, + + onChangeUploadings(uploads) { + this.$emit('change-uploadings', uploads); + }, + + onPollUpdate() { + const got = this.$refs.poll.get(); + this.pollChoices = got.choices; + this.pollMultiple = got.multiple; + this.pollExpiration = [got.expiration, got.expiresAt || got.expiredAfter]; + this.saveDraft(); + }, + + setVisibility() { + const w = this.$root.new(MkVisibilityChooser, { + source: this.$refs.visibilityButton, + currentVisibility: this.visibility + }); + w.$once('chosen', v => { + this.applyVisibility(v); + }); + }, + + applyVisibility(v: string) { + this.visibility = v; + }, + + addVisibleUser() { + const vm = this.$root.new(MkUserSelect, {}); + vm.$once('selected', user => { + this.visibleUsers.push(user); + }); + }, + + removeVisibleUser(user) { + this.visibleUsers = erase(user, this.visibleUsers); + }, + + clear() { + this.text = ''; + this.files = []; + this.poll = false; + this.quoteId = null; + this.$emit('change-attached-files', this.files); + }, + + onKeydown(e) { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); + }, + + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { + if (item.kind == 'file') { + const file = item.getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + this.upload(file, formatted); + } + } + + const paste = e.clipboardData.getData('text'); + + if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { + e.preventDefault(); + + this.$root.dialog({ + type: 'info', + text: this.$t('@.post-form.quote-question'), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) { + insertTextAtCursor(this.$refs.text, paste); + return; + } + + this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + }); + } + }, + + onDragover(e) { + if (!e.dataTransfer.items[0]) return; + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDragenter(e) { + this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + for (const x of Array.from(e.dataTransfer.files)) this.upload(x); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + this.$emit('change-attached-files', this.files); + e.preventDefault(); + } + //#endregion + }, + + saveDraft() { + if (this.instant) return; + + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftId] = { + updatedAt: new Date(), + data: { + text: this.text, + files: this.files, + poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined + } + }; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftId]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + post() { + this.posting = true; + this.$root.api('notes/create', { + text: this.text == '' ? undefined : this.text, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + replyId: this.reply ? this.reply.id : undefined, + renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, + poll: this.poll ? (this.$refs.poll as any).get() : undefined, + cw: this.useCw ? this.cw || '' : undefined, + visibility: this.visibility, + visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, + viaMobile: this.$root.isMobile + }).then(data => { + this.clear(); + this.deleteDraft(); + this.$emit('posted'); + }).catch(err => { + }).then(() => { + this.posting = false; + }); + + if (this.text && this.text != '') { + const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag); + const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); + } + }, + + cancel() { + this.$emit('cancel'); + }, + + insertMention() { + const vm = this.$root.new(MkUserSelect, {}); + vm.$once('selected', user => { + insertTextAtCursor(this.$refs.text, getAcct(user) + ' '); + }); + }, + + insertEmoji(ev) { + const vm = this.$root.new(XEmojiPicker, { + source: ev.currentTarget || ev.target + }).$once('chosen', emoji => { + insertTextAtCursor(this.$refs.text, emoji); + vm.close(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.gafaadew { + background: var(--panel); + border-radius: var(--radius); + box-shadow: 0 0 2px rgba(#000, 0.1); + + > header { + z-index: 1000; + height: 66px; + + @media (max-width: 500px) { + height: 50px; + } + + > .cancel { + padding: 0; + font-size: 20px; + width: 64px; + line-height: 66px; + + @media (max-width: 500px) { + width: 50px; + line-height: 50px; + } + } + + > div { + position: absolute; + top: 0; + right: 0; + + > .text-count { + line-height: 66px; + + @media (max-width: 500px) { + line-height: 50px; + } + } + + > .submit { + margin: 16px; + padding: 0 16px; + line-height: 34px; + vertical-align: bottom; + border-radius: 4px; + + @media (max-width: 500px) { + margin: 8px; + } + + &:disabled { + opacity: 0.7; + } + } + } + } + + > .form { + max-width: 500px; + margin: 0 auto; + + > .preview { + padding: 16px; + } + + > .with-quote { + margin: 0 0 8px 0; + color: var(--accent); + + > button { + padding: 4px 8px; + color: var(--accentAlpha04); + + &:hover { + color: var(--accentAlpha06); + } + + &:active { + color: var(--accentDarken30); + } + } + } + + > .to-specified { + padding: 6px 24px; + margin-bottom: 8px; + overflow: auto; + white-space: nowrap; + + @media (max-width: 500px) { + padding: 6px 16px; + } + + > .visibleUsers { + display: inline; + top: -1px; + font-size: 14px; + + > button { + padding: 4px; + border-radius: 8px; + } + + > span { + margin-right: 14px; + padding: 8px 0 8px 8px; + border-radius: 8px; + background: var(--nwjktjjq); + + > button { + padding: 4px 8px; + } + } + } + } + + > input { + z-index: 1; + } + + > input, + > textarea { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: initial; + + @media (max-width: 500px) { + padding: 0 16px; + } + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + > textarea { + max-width: 100%; + min-width: 100%; + min-height: 90px; + + @media (max-width: 500px) { + min-height: 80px; + } + } + + > .mk-uploader { + margin: 8px 0 0 0; + padding: 8px; + } + + > .file { + display: none; + } + + > footer { + padding: 0 16px 16px 16px; + + @media (max-width: 500px) { + padding: 0 8px 8px 8px; + } + + > * { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: 48px; + height: 48px; + border-radius: 6px; + + &:hover { + background: var(--geavgsxy); + } + } + } + } +} +</style> diff --git a/src/client/components/reaction-icon.vue b/src/client/components/reaction-icon.vue new file mode 100644 index 0000000000..368ddc0efc --- /dev/null +++ b/src/client/components/reaction-icon.vue @@ -0,0 +1,32 @@ +<template> +<mk-emoji :emoji="reaction.startsWith(':') ? null : reaction" :name="reaction.startsWith(':') ? reaction.substr(1, reaction.length - 2) : null" :is-reaction="true" :custom-emojis="customEmojis" :normal="true" :no-style="noStyle"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +export default Vue.extend({ + i18n, + props: { + reaction: { + type: String, + required: true + }, + noStyle: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + customEmojis: [] + }; + }, + created() { + this.$root.getMeta().then(meta => { + if (meta && meta.emojis) this.customEmojis = meta.emojis; + }); + }, +}); +</script> diff --git a/src/client/components/reaction-picker.vue b/src/client/components/reaction-picker.vue new file mode 100644 index 0000000000..00b964f07c --- /dev/null +++ b/src/client/components/reaction-picker.vue @@ -0,0 +1,229 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap"> + <div class="rdfaahpb"> + <transition-group + name="reaction-fade" + tag="div" + class="buttons" + ref="buttons" + :class="{ showFocus }" + :css="false" + @before-enter="beforeEnter" + @enter="enter" + mode="out-in" + appear + > + <button class="_button" v-for="(reaction, i) in rs" :key="reaction" @click="react(reaction)" :data-index="i" :tabindex="i + 1" :title="/^[a-z]+$/.test(reaction) ? $t('@.reactions.' + reaction) : reaction"><x-reaction-icon :reaction="reaction"/></button> + </transition-group> + <input class="text" v-model="text" :placeholder="$t('enterEmoji')" @keyup.enter="reactText" @input="tryReactText" v-autocomplete="{ model: 'text' }"> + </div> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { emojiRegex } from '../../misc/emoji-regex'; +import XReactionIcon from './reaction-icon.vue'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XPopup, + XReactionIcon, + }, + + props: { + source: { + required: true + }, + + reactions: { + required: false + }, + + showFocus: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + return { + rs: this.reactions || this.$store.state.settings.reactions, + text: null, + focus: null + }; + }, + + computed: { + keymap(): any { + return { + 'esc': this.close, + 'enter|space|plus': this.choose, + 'up|k': this.focusUp, + 'left|h|shift+tab': this.focusLeft, + 'right|l|tab': this.focusRight, + 'down|j': this.focusDown, + '1': () => this.react(this.rs[0]), + '2': () => this.react(this.rs[1]), + '3': () => this.react(this.rs[2]), + '4': () => this.react(this.rs[3]), + '5': () => this.react(this.rs[4]), + '6': () => this.react(this.rs[5]), + '7': () => this.react(this.rs[6]), + '8': () => this.react(this.rs[7]), + '9': () => this.react(this.rs[8]), + '0': () => this.react(this.rs[9]), + }; + }, + }, + + watch: { + focus(i) { + this.$refs.buttons.children[i].elm.focus(); + } + }, + + mounted() { + this.focus = 0; + }, + + methods: { + close() { + this.$refs.popup.close(); + }, + + react(reaction) { + this.$emit('chosen', reaction); + }, + + reactText() { + if (!this.text) return; + this.react(this.text); + }, + + tryReactText() { + if (!this.text) return; + if (!this.text.match(emojiRegex)) return; + this.reactText(); + }, + + focusUp() { + this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5); + }, + + focusDown() { + this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5); + }, + + focusRight() { + this.focus = this.focus == 9 ? 0 : (this.focus + 1); + }, + + focusLeft() { + this.focus = this.focus == 0 ? 9 : (this.focus - 1); + }, + + choose() { + this.$refs.buttons.children[this.focus].elm.click(); + }, + + beforeEnter(el) { + el.style.opacity = 0; + el.style.transform = 'scale(0.7)'; + }, + + enter(el, done) { + el.style.transition = [getComputedStyle(el).transition, 'transform 1s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(','); + setTimeout(() => { + el.style.opacity = 1; + el.style.transform = 'scale(1)'; + setTimeout(done, 1000); + }, 0 * el.dataset.index) + }, + } +}); +</script> + +<style lang="scss" scoped> +.rdfaahpb { + > .buttons { + padding: 6px 6px 0 6px; + width: 212px; + box-sizing: border-box; + text-align: center; + + @media (max-width: 1025px) { + padding: 8px 8px 0 8px; + width: 256px; + } + + &.showFocus { + > button:focus { + z-index: 1; + + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + border: 2px solid var(--focus); + border-radius: 4px; + } + } + } + + > button { + padding: 0; + width: 40px; + height: 40px; + font-size: 24px; + border-radius: 2px; + + @media (max-width: 1025px) { + width: 48px; + height: 48px; + font-size: 26px; + } + + > * { + height: 1em; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + } + } + + > .text { + width: 208px; + padding: 8px; + margin: 0 0 6px 0; + box-sizing: border-box; + text-align: center; + font-size: 16px; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + @media (max-width: 1025px) { + width: 256px; + margin: 4px 0 8px 0; + } + } +} +</style> diff --git a/src/client/components/reactions-viewer.details.vue b/src/client/components/reactions-viewer.details.vue new file mode 100644 index 0000000000..ea2523a11f --- /dev/null +++ b/src/client/components/reactions-viewer.details.vue @@ -0,0 +1,117 @@ +<template> +<transition name="zoom-in-top"> + <div class="buebdbiu" ref="popover" v-if="show"> + <template v-if="users.length <= 10"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + </template> + <template v-if="10 < users.length"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <mk-avatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <mk-user-name :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + <span slot="omitted">+{{ count - 10 }}</span> + </template> + </div> +</transition> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + props: { + reaction: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + count: { + type: Number, + required: true, + }, + source: { + required: true, + } + }, + data() { + return { + show: false + }; + }, + mounted() { + this.show = true; + + this.$nextTick(() => { + const popover = this.$refs.popover as any; + + if (this.source == null) { + this.destroyDom(); + return; + } + const rect = this.source.getBoundingClientRect(); + + const x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + const y = rect.top + window.pageYOffset + this.source.offsetHeight; + popover.style.left = (x - 28) + 'px'; + popover.style.top = (y + 16) + 'px'; + }); + } + methods: { + close() { + this.show = false; + setTimeout(this.destroyDom, 300); + } + } +}) +</script> + +<style lang="scss" scoped> +.buebdbiu { + z-index: 10000; + display: block; + position: absolute; + max-width: 240px; + font-size: 0.8em; + padding: 6px 8px; + background: var(--panel); + text-align: center; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); + pointer-events: none; + transform-origin: center -16px; + + &:before { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: -28px; + left: 12px; + border-top: solid 14px transparent; + border-right: solid 14px transparent; + border-bottom: solid 14px rgba(0,0,0,0.1); + border-left: solid 14px transparent; + } + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: -27px; + left: 12px; + border-top: solid 14px transparent; + border-right: solid 14px transparent; + border-bottom: solid 14px var(--panel); + border-left: solid 14px transparent; + } +} +</style> diff --git a/src/client/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue new file mode 100644 index 0000000000..a878a283ff --- /dev/null +++ b/src/client/components/reactions-viewer.reaction.vue @@ -0,0 +1,167 @@ +<template> +<span + class="reaction _button" + :class="{ reacted: note.myReaction == reaction }" + @click="toggleReaction(reaction)" + v-if="count > 0" + @mouseover="onMouseover" + @mouseleave="onMouseleave" + ref="reaction" +> + <x-reaction-icon :reaction="reaction" ref="icon"/> + <span>{{ count }}</span> +</span> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XDetails from './reactions-viewer.details.vue'; +import XReactionIcon from './reaction-icon.vue'; + +export default Vue.extend({ + components: { + XReactionIcon + }, + props: { + reaction: { + type: String, + required: true, + }, + count: { + type: Number, + required: true, + }, + isInitial: { + type: Boolean, + required: true, + }, + note: { + type: Object, + required: true, + }, + canToggle: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + details: null, + detailsTimeoutId: null, + isHovering: false + }; + }, + computed: { + isMe(): boolean { + return this.$store.getters.isSignedIn && this.$store.state.i.id === this.note.userId; + }, + }, + mounted() { + if (!this.isInitial) this.anime(); + }, + watch: { + count(newCount, oldCount) { + if (oldCount < newCount) this.anime(); + if (this.details != null) this.openDetails(); + }, + }, + methods: { + toggleReaction() { + if (this.isMe) return; + if (!this.canToggle) return; + + const oldReaction = this.note.myReaction; + if (oldReaction) { + this.$root.api('notes/reactions/delete', { + noteId: this.note.id + }).then(() => { + if (oldReaction !== this.reaction) { + this.$root.api('notes/reactions/create', { + noteId: this.note.id, + reaction: this.reaction + }); + } + }); + } else { + this.$root.api('notes/reactions/create', { + noteId: this.note.id, + reaction: this.reaction + }); + } + }, + onMouseover() { + this.isHovering = true; + this.detailsTimeoutId = setTimeout(this.openDetails, 300); + }, + onMouseleave() { + this.isHovering = false; + clearTimeout(this.detailsTimeoutId); + this.closeDetails(); + }, + openDetails() { + if (this.$root.isMobile) return; + this.$root.api('notes/reactions', { + noteId: this.note.id, + type: this.reaction, + limit: 11 + }).then((reactions: any[]) => { + const users = reactions + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map(x => x.user); + + this.closeDetails(); + if (!this.isHovering) return; + this.details = this.$root.new(XDetails, { + reaction: this.reaction, + users, + count: this.count, + source: this.$refs.reaction + }); + }); + }, + closeDetails() { + if (this.details != null) { + this.details.close(); + this.details = null; + } + }, + anime() { + if (document.hidden) return; + + // TODO + }, + } +}); +</script> + +<style lang="scss" scoped> +.reaction { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + border-radius: 4px; + + &.reacted { + background: var(--accent); + + > span { + color: #fff; + } + } + + &:not(.reacted) { + background: rgba(0, 0, 0, 0.05); + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } + + > span { + font-size: 0.9em; + line-height: 32px; + } +} +</style> diff --git a/src/client/components/reactions-viewer.vue b/src/client/components/reactions-viewer.vue new file mode 100644 index 0000000000..d089cf682c --- /dev/null +++ b/src/client/components/reactions-viewer.vue @@ -0,0 +1,48 @@ +<template> +<div class="mk-reactions-viewer" :class="{ isMe }"> + <x-reaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XReaction from './reactions-viewer.reaction.vue'; + +export default Vue.extend({ + components: { + XReaction + }, + data() { + return { + initialReactions: new Set(Object.keys(this.note.reactions)) + }; + }, + props: { + note: { + type: Object, + required: true + }, + }, + computed: { + isMe(): boolean { + return this.$store.getters.isSignedIn && this.$store.state.i.id === this.note.userId; + }, + }, +}); +</script> + +<style lang="scss" scoped> +.mk-reactions-viewer { + margin: 4px -2px 0 -2px; + + &:empty { + display: none; + } + + &.isMe { + > span { + cursor: default !important; + } + } +} +</style> diff --git a/src/client/components/renote-picker.vue b/src/client/components/renote-picker.vue new file mode 100644 index 0000000000..d8258d5f5d --- /dev/null +++ b/src/client/components/renote-picker.vue @@ -0,0 +1,94 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }" v-hotkey.global="keymap"> + <div class="rdfaahpc"> + <button class="_button" @click="renote()"><fa :icon="faRetweet"/></button> + <button class="_button" @click="quote()"><fa :icon="faQuoteRight"/></button> + </div> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faQuoteRight, faRetweet } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XPopup, + }, + + props: { + note: { + type: Object, + required: true + }, + + source: { + required: true + }, + }, + + data() { + return { + faQuoteRight, faRetweet + }; + }, + + computed: { + keymap(): any { + return { + 'esc': this.close, + }; + } + }, + + methods: { + renote() { + (this as any).$root.api('notes/create', { + renoteId: this.note.id + }).then(() => { + this.$emit('closed'); + this.destroyDom(); + }); + }, + + quote() { + this.$emit('closed'); + this.destroyDom(); + this.$root.post({ + renote: this.note, + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.rdfaahpc { + padding: 4px; + + > button { + padding: 0; + width: 40px; + height: 40px; + font-size: 16px; + border-radius: 2px; + + > * { + height: 1em; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + } +} +</style> diff --git a/src/client/components/sequential-entrance.vue b/src/client/components/sequential-entrance.vue new file mode 100644 index 0000000000..70e486719e --- /dev/null +++ b/src/client/components/sequential-entrance.vue @@ -0,0 +1,63 @@ +<template> +<transition-group + name="staggered-fade" + tag="div" + :css="false" + @before-enter="beforeEnter" + @enter="enter" + @leave="leave" + mode="out-in" + appear +> + <slot></slot> +</transition-group> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + delay: { + type: Number, + required: false, + default: 40 + }, + direction: { + type: String, + required: false, + default: 'down' + } + }, + methods: { + beforeEnter(el) { + el.style.opacity = 0; + el.style.transform = this.direction === 'down' ? 'translateY(-64px)' : 'translateY(64px)'; + }, + enter(el, done) { + el.style.transition = [getComputedStyle(el).transition, 'transform 0.7s cubic-bezier(0.23, 1, 0.32, 1)', 'opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1)'].filter(x => x != '').join(','); + setTimeout(() => { + el.style.opacity = 1; + el.style.transform = 'translateY(0px)'; + setTimeout(done, 700); + }, this.delay * el.dataset.index) + }, + leave(el, done) { + setTimeout(() => { + el.style.opacity = 0; + el.style.transform = this.direction === 'down' ? 'translateY(64px)' : 'translateY(-64px)'; + setTimeout(done, 700); + }, this.delay * el.dataset.index) + }, + focus() { + this.$slots.default[0].elm.focus(); + } + } +}); +</script> + +<style lang="scss"> +.staggered-fade-move { + transition: transform 0.7s !important; +} +</style> diff --git a/src/client/components/signin-dialog.vue b/src/client/components/signin-dialog.vue new file mode 100644 index 0000000000..dbc63c93bf --- /dev/null +++ b/src/client/components/signin-dialog.vue @@ -0,0 +1,37 @@ +<template> +<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }"> + <template #header>{{ $t('login') }}</template> + <x-signin :auto-set="autoSet" @login="onLogin"/> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import XWindow from './window.vue'; +import XSignin from './signin.vue'; + +export default Vue.extend({ + i18n, + + components: { + XSignin, + XWindow, + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + methods: { + onLogin(res) { + this.$emit('login', res); + this.$refs.window.close(); + } + } +}); +</script> diff --git a/src/client/components/signin.vue b/src/client/components/signin.vue new file mode 100644 index 0000000000..dc6fad1c5d --- /dev/null +++ b/src/client/components/signin.vue @@ -0,0 +1,219 @@ +<template> +<form class="eppvobhk" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> + <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> + <div class="normal-signin" v-if="!totpLogin"> + <mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @input="onUsernameChange"> + <span>{{ $t('username') }}</span> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </mk-input> + <mk-input v-model="password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required> + <span>{{ $t('password') }}</span> + <template #prefix><fa :icon="faLock"/></template> + </mk-input> + <mk-button type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button> + <p v-if="meta && meta.enableTwitterIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/twitter`"><fa :icon="['fab', 'twitter']"/> {{ $t('signin-with-twitter') }}</a></p> + <p v-if="meta && meta.enableGithubIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/github`"><fa :icon="['fab', 'github']"/> {{ $t('signin-with-github') }}</a></p> + <p v-if="meta && meta.enableDiscordIntegration" style="margin: 8px 0;"><a :href="`${apiUrl}/signin/discord`"><fa :icon="['fab', 'discord']"/> {{ $t('signin-with-discord') /* TODO: Make these layouts better */ }}</a></p> + </div> + <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> + <div v-if="user && user.securityKeys" class="twofa-group tap-group"> + <p>{{ $t('tap-key') }}</p> + <mk-button @click="queryKey" v-if="!queryingKey"> + {{ $t('@.error.retry') }} + </mk-button> + </div> + <div class="or-hr" v-if="user && user.securityKeys"> + <p class="or-msg">{{ $t('or') }}</p> + </div> + <div class="twofa-group totp-group"> + <p style="margin-bottom:0;">{{ $t('twoStepAuthentication') }}</p> + <mk-input v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> + <span>{{ $t('password') }}</span> + <template #prefix><fa :icon="faLock"/></template> + </mk-input> + <mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <span>{{ $t('token') }}</span> + <template #prefix><fa :icon="faGavel"/></template> + </mk-input> + <mk-button type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $t('loggingIn') : $t('login') }}</mk-button> + </div> + </div> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { toUnicode } from 'punycode'; +import { faLock, faGavel } from '@fortawesome/free-solid-svg-icons'; +import MkButton from './ui/button.vue'; +import MkInput from './ui/input.vue'; +import i18n from '../i18n'; +import { apiUrl, host } from '../config'; +import { hexifyAB } from '../scripts/2fa'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkInput, + }, + + props: { + withAvatar: { + type: Boolean, + required: false, + default: true + }, + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '', + apiUrl, + host: toUnicode(host), + meta: null, + totpLogin: false, + credential: null, + challengeData: null, + queryingKey: false, + faLock, faGavel + }; + }, + + created() { + this.$root.getMeta().then(meta => { + this.meta = meta; + }); + + if (this.autoSet) { + this.$once('login', res => { + localStorage.setItem('i', res.i); + location.reload(); + }); + } + }, + + methods: { + onUsernameChange() { + this.$root.api('users/show', { + username: this.username + }).then(user => { + this.user = user; + }, () => { + this.user = null; + }); + }, + + queryKey() { + this.queryingKey = true; + return navigator.credentials.get({ + publicKey: { + challenge: Buffer.from( + this.challengeData.challenge + .replace(/\-/g, '+') + .replace(/_/g, '/'), + 'base64' + ), + allowCredentials: this.challengeData.securityKeys.map(key => ({ + id: Buffer.from(key.id, 'hex'), + type: 'public-key', + transports: ['usb', 'nfc', 'ble', 'internal'] + })), + timeout: 60 * 1000 + } + }).catch(() => { + this.queryingKey = false; + return Promise.reject(null); + }).then(credential => { + this.queryingKey = false; + this.signing = true; + return this.$root.api('signin', { + username: this.username, + password: this.password, + signature: hexifyAB(credential.response.signature), + authenticatorData: hexifyAB(credential.response.authenticatorData), + clientDataJSON: hexifyAB(credential.response.clientDataJSON), + credentialId: credential.id, + challengeId: this.challengeData.challengeId + }); + }).then(res => { + this.$emit('login', res); + }).catch(err => { + if (err === null) return; + this.$root.dialog({ + type: 'error', + text: this.$t('login-failed') + }); + this.signing = false; + }); + }, + + onSubmit() { + this.signing = true; + if (!this.totpLogin && this.user && this.user.twoFactorEnabled) { + if (window.PublicKeyCredential && this.user.securityKeys) { + this.$root.api('signin', { + username: this.username, + password: this.password + }).then(res => { + this.totpLogin = true; + this.signing = false; + this.challengeData = res; + return this.queryKey(); + }).catch(() => { + this.$root.dialog({ + type: 'error', + text: this.$t('login-failed') + }); + this.challengeData = null; + this.totpLogin = false; + this.signing = false; + }); + } else { + this.totpLogin = true; + this.signing = false; + } + } else { + this.$root.api('signin', { + username: this.username, + password: this.password, + token: this.user && this.user.twoFactorEnabled ? this.token : undefined + }).then(res => { + this.$emit('login', res); + }).catch(() => { + this.$root.dialog({ + type: 'error', + text: this.$t('loginFailed') + }); + this.signing = false; + }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.eppvobhk { + > .avatar { + margin: 0 auto 0 auto; + width: 64px; + height: 64px; + background: #ddd; + background-position: center; + background-size: cover; + border-radius: 100%; + } +} +</style> diff --git a/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue new file mode 100644 index 0000000000..76421d44ec --- /dev/null +++ b/src/client/components/signup-dialog.vue @@ -0,0 +1,22 @@ +<template> +<x-window @closed="() => { $emit('closed'); destroyDom(); }"> + <template #header>{{ $t('signup') }}</template> + <x-signup/> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import XWindow from './window.vue'; +import XSignup from './signup.vue'; + +export default Vue.extend({ + i18n, + + components: { + XSignup, + XWindow, + }, +}); +</script> diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue new file mode 100644 index 0000000000..c03a99def6 --- /dev/null +++ b/src/client/components/signup.vue @@ -0,0 +1,191 @@ +<template> +<form class="mk-signup" @submit.prevent="onSubmit" :autocomplete="Math.random()"> + <template v-if="meta"> + <mk-input v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> + <span>{{ $t('invitation-code') }}</span> + <template #prefix><fa icon="id-card-alt"/></template> + <template #desc v-html="this.$t('invitation-info').replace('{}', 'mailto:' + meta.maintainerEmail)"></template> + </mk-input> + <mk-input v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @input="onChangeUsername"> + <span>{{ $t('username') }}</span> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + <template #desc> + <span v-if="usernameState == 'wait'" style="color:#999"><fa :icon="faSpinner" pulse fixed-width/> {{ $t('checking') }}</span> + <span v-if="usernameState == 'ok'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('available') }}</span> + <span v-if="usernameState == 'unavailable'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('unavailable') }}</span> + <span v-if="usernameState == 'error'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('error') }}</span> + <span v-if="usernameState == 'invalid-format'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('invalid-format') }}</span> + <span v-if="usernameState == 'min-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-short') }}</span> + <span v-if="usernameState == 'max-range'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('too-long') }}</span> + </template> + </mk-input> + <mk-input v-model="password" type="password" :autocomplete="Math.random()" required @input="onChangePassword"> + <span>{{ $t('password') }}</span> + <template #prefix><fa :icon="faLock"/></template> + <template #desc> + <p v-if="passwordStrength == 'low'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('weak-password') }}</p> + <p v-if="passwordStrength == 'medium'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('normal-password') }}</p> + <p v-if="passwordStrength == 'high'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('strong-password') }}</p> + </template> + </mk-input> + <mk-input v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @input="onChangePasswordRetype"> + <span>{{ $t('password') }} ({{ $t('retype') }})</span> + <template #prefix><fa :icon="faLock"/></template> + <template #desc> + <p v-if="passwordRetypeState == 'match'" style="color:#3CB7B5"><fa :icon="faCheck" fixed-width/> {{ $t('password-matched') }}</p> + <p v-if="passwordRetypeState == 'not-match'" style="color:#FF1161"><fa :icon="faExclamationTriangle" fixed-width/> {{ $t('password-not-matched') }}</p> + </template> + </mk-input> + <mk-switch v-model="ToSAgreement" v-if="meta.tosUrl"> + <i18n path="agreeTo"> + <a :href="meta.tosUrl" target="_blank">{{ $t('tos') }}</a> + </i18n> + </mk-switch> + <div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div> + <mk-button type="submit" :disabled=" submitting || !(meta.tosUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'" primary>{{ $t('start') }}</mk-button> + </template> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock, faExclamationTriangle, faSpinner, faCheck } from '@fortawesome/free-solid-svg-icons'; +const getPasswordStrength = require('syuilo-password-strength'); +import { toUnicode } from 'punycode'; +import i18n from '../i18n'; +import { host, url } from '../config'; +import MkButton from './ui/button.vue'; +import MkInput from './ui/input.vue'; +import MkSwitch from './ui/switch.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkInput, + MkSwitch, + }, + + data() { + return { + host: toUnicode(host), + username: '', + password: '', + retypedPassword: '', + invitationCode: '', + url, + usernameState: null, + passwordStrength: '', + passwordRetypeState: null, + meta: {}, + submitting: false, + ToSAgreement: false, + faLock, faExclamationTriangle, faSpinner, faCheck + } + }, + + computed: { + shouldShowProfileUrl(): boolean { + return (this.username != '' && + this.usernameState != 'invalid-format' && + this.usernameState != 'min-range' && + this.usernameState != 'max-range'); + } + }, + + created() { + this.$root.getMeta().then(meta => { + this.meta = meta; + }); + }, + + mounted() { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('src', 'https://www.google.com/recaptcha/api.js'); + head.appendChild(script); + }, + + methods: { + onChangeUsername() { + if (this.username == '') { + this.usernameState = null; + return; + } + + const err = + !this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : + this.username.length < 1 ? 'min-range' : + this.username.length > 20 ? 'max-range' : + null; + + if (err) { + this.usernameState = err; + return; + } + + this.usernameState = 'wait'; + + this.$root.api('username/available', { + username: this.username + }).then(result => { + this.usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.usernameState = 'error'; + }); + }, + + onChangePassword() { + if (this.password == '') { + this.passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(this.password); + this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; + }, + + onChangePasswordRetype() { + if (this.retypedPassword == '') { + this.passwordRetypeState = null; + return; + } + + this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; + }, + + onSubmit() { + if (this.submitting) return; + this.submitting = true; + + this.$root.api('signup', { + username: this.username, + password: this.password, + invitationCode: this.invitationCode, + 'g-recaptcha-response': this.meta.enableRecaptcha ? (window as any).grecaptcha.getResponse() : null + }).then(() => { + this.$root.api('signin', { + username: this.username, + password: this.password + }).then(res => { + localStorage.setItem('i', res.i); + location.href = '/'; + }); + }).catch(() => { + this.submitting = false; + + this.$root.dialog({ + type: 'error', + text: this.$t('some-error') + }); + + if (this.meta.enableRecaptcha) { + (window as any).grecaptcha.reset(); + } + }); + } + } +}); +</script> diff --git a/src/client/components/sub-note-content.vue b/src/client/components/sub-note-content.vue new file mode 100644 index 0000000000..e60c197442 --- /dev/null +++ b/src/client/components/sub-note-content.vue @@ -0,0 +1,65 @@ +<template> +<div class="wrmlmaau"> + <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">({{ $t('private') }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ $t('deleted') }})</span> + <router-link class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><fa :icon="faReply"/></router-link> + <mfm v-if="note.text" :text="note.text" :author="note.user" :i="$store.state.i" :custom-emojis="note.emojis"/> + <router-link class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</router-link> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <x-media-list :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ $t('poll') }}</summary> + <x-poll :note="note"/> + </details> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faReply } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XPoll from './poll.vue'; +import XMediaList from './media-list.vue'; + +export default Vue.extend({ + i18n, + components: { + XPoll, + XMediaList, + }, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + faReply + }; + } +}); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } +} +</style> diff --git a/src/client/components/time.vue b/src/client/components/time.vue new file mode 100644 index 0000000000..922067b4d5 --- /dev/null +++ b/src/client/components/time.vue @@ -0,0 +1,74 @@ +<template> +<time class="mk-time" :title="absolute"> + <span v-if="mode == 'relative'">{{ relative }}</span> + <span v-if="mode == 'absolute'">{{ absolute }}</span> + <span v-if="mode == 'detail'">{{ absolute }} ({{ relative }})</span> +</time> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; + +export default Vue.extend({ + i18n, + props: { + time: { + type: [Date, String], + required: true + }, + mode: { + type: String, + default: 'relative' + } + }, + data() { + return { + tickId: null, + now: new Date() + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + }, + absolute(): string { + return this._time.toLocaleString(); + }, + relative(): string { + const time = this._time; + const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; + return ( + ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) : + ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) : + ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) : + ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) : + ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) : + ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? this.$t('_ago.justNow') : + ago < -1 ? this.$t('_ago.future') : + this.$t('@.time.unknown')); + } + }, + created() { + if (this.mode == 'relative' || this.mode == 'detail') { + this.tickId = window.requestAnimationFrame(this.tick); + } + }, + destroyed() { + if (this.mode === 'relative' || this.mode === 'detail') { + window.clearTimeout(this.tickId); + } + }, + methods: { + tick() { + this.now = new Date(); + + this.tickId = setTimeout(() => { + window.requestAnimationFrame(this.tick); + }, 10000); + } + } +}); +</script> diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue new file mode 100644 index 0000000000..f5edb18550 --- /dev/null +++ b/src/client/components/timeline.vue @@ -0,0 +1,118 @@ +<template> +<x-notes ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotes from './notes.vue'; + +export default Vue.extend({ + components: { + XNotes + }, + + props: { + src: { + type: String, + required: true + }, + list: { + required: false + }, + antenna: { + required: false + } + }, + + data() { + return { + connection: null, + pagination: null, + baseQuery: { + includeMyRenotes: this.$store.state.settings.showMyRenotes, + includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.settings.showLocalRenotes + }, + query: {}, + }; + }, + + created() { + this.$once('hook:beforeDestroy', () => { + this.connection.dispose(); + }); + + const prepend = note => { + (this.$refs.tl as any).prepend(note); + }; + + const onUserAdded = () => { + (this.$refs.tl as any).reload(); + }; + + const onUserRemoved = () => { + (this.$refs.tl as any).reload(); + }; + + let endpoint; + + if (this.src == 'antenna') { + endpoint = 'antennas/notes'; + this.query = { + antennaId: this.antenna.id + }; + this.connection = this.$root.stream.connectToChannel('antenna', { + antennaId: this.antenna.id + }); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + endpoint = 'notes/timeline'; + const onChangeFollowing = () => { + this.fetch(); + }; + this.connection = this.$root.stream.useSharedConnection('homeTimeline'); + this.connection.on('note', prepend); + this.connection.on('follow', onChangeFollowing); + this.connection.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + endpoint = 'notes/local-timeline'; + this.connection = this.$root.stream.useSharedConnection('localTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'social') { + endpoint = 'notes/hybrid-timeline'; + this.connection = this.$root.stream.useSharedConnection('hybridTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + endpoint = 'notes/global-timeline'; + this.connection = this.$root.stream.useSharedConnection('globalTimeline'); + this.connection.on('note', prepend); + } else if (this.src == 'list') { + endpoint = 'notes/user-list-timeline'; + this.query = { + listId: this.list.id + }; + this.connection = this.$root.stream.connectToChannel('userList', { + listId: this.list.id + }); + this.connection.on('note', prepend); + this.connection.on('userAdded', onUserAdded); + this.connection.on('userRemoved', onUserRemoved); + } + + this.pagination = { + endpoint: endpoint, + limit: 10, + params: init => ({ + untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + ...this.baseQuery, ...this.query + }) + }; + }, + + methods: { + focus() { + this.$refs.tl.focus(); + } + } +}); +</script> diff --git a/src/client/components/toast.vue b/src/client/components/toast.vue new file mode 100644 index 0000000000..fefe91e3bd --- /dev/null +++ b/src/client/components/toast.vue @@ -0,0 +1,76 @@ +<template> +<div class="mk-toast"> + <transition name="notification-slide" appear @after-leave="() => { destroyDom(); }"> + <x-notification :notification="notification" class="notification" v-if="show"/> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XNotification from './notification.vue'; + +export default Vue.extend({ + components: { + XNotification + }, + props: { + notification: { + type: Object, + required: true + } + }, + data() { + return { + show: true + }; + }, + mounted() { + setTimeout(() => { + this.show = false; + }, 6000); + } +}); +</script> + +<style lang="scss" scoped> +.notification-slide-enter-active, .notification-slide-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.notification-slide-enter, .notification-slide-leave-to { + opacity: 0; + transform: translateX(-250px); +} + +.mk-toast { + position: fixed; + z-index: 10000; + left: 0; + width: 250px; + top: 32px; + padding: 0 32px; + pointer-events: none; + + @media (max-width: 700px) { + top: initial; + bottom: 112px; + padding: 0 16px; + } + + @media (max-width: 500px) { + bottom: 92px; + padding: 0 8px; + } + + > .notification { + height: 100%; + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + background-color: var(--toastBg); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; + color: var(--toastFg); + overflow: hidden; + } +} +</style> diff --git a/src/client/components/ui/button.vue b/src/client/components/ui/button.vue new file mode 100644 index 0000000000..4071faa1dd --- /dev/null +++ b/src/client/components/ui/button.vue @@ -0,0 +1,204 @@ +<template> +<component class="bghgjjyj _button" + :is="link ? 'a' : 'button'" + :class="{ inline, primary }" + :type="type" + @click="$emit('click', $event)" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + type: { + type: String, + required: false + }, + primary: { + type: Boolean, + required: false, + default: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + link: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + wait: { + type: Boolean, + required: false, + default: false + }, + }, + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$el.focus(); + }); + } + }, + methods: { + onMousedown(e: MouseEvent) { + function distance(p, q) { + return Math.hypot(p.x - q.x, p.y - q.y); + } + + function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { + const origin = {x: circleCenterX, y: circleCenterY}; + const dist1 = distance({x: 0, y: 0}, origin); + const dist2 = distance({x: boxW, y: 0}, origin); + const dist3 = distance({x: 0, y: boxH}, origin); + const dist4 = distance({x: boxW, y: boxH }, origin); + return Math.max(dist1, dist2, dist3, dist4) * 2; + } + + const rect = e.target.getBoundingClientRect(); + + const ripple = document.createElement('div'); + ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px'; + ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px'; + + this.$refs.ripples.appendChild(ripple); + + const circleCenterX = e.clientX - rect.left; + const circleCenterY = e.clientY - rect.top; + + const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); + + setTimeout(() => { + ripple.style.transform = 'scale(' + (scale / 2) + ')'; + }, 1); + setTimeout(() => { + ripple.style.transition = 'all 1s ease'; + ripple.style.opacity = '0'; + }, 1000); + setTimeout(() => { + if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); + }, 2000); + } + } +}); +</script> + +<style lang="scss" scoped> +.bghgjjyj { + position: relative; + display: block; + min-width: 100px; + padding: 8px 14px; + text-align: center; + font-weight: normal; + font-size: 14px; + line-height: 24px; + box-shadow: none; + text-decoration: none; + background: var(--buttonBg); + border-radius: 6px; + overflow: hidden; + + &:not(:disabled):hover { + background: var(--buttonHoverBg); + } + + &:not(:disabled):active { + background: var(--buttonHoverBg); + } + + &.primary { + color: #fff; + background: var(--accent); + + &:not(:disabled):hover { + background: var(--jkhztclx); + } + + &:not(:disabled):active { + background: var(--jkhztclx); + } + } + + &:disabled { + opacity: 0.7; + } + + &:focus { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--accentAlpha03); + border-radius: 10px; + } + } + + &.inline + .bghgjjyj { + margin-left: 12px; + } + + &:not(.inline) + .bghgjjyj { + margin-top: 16px; + } + + &.inline { + display: inline-block; + width: auto; + min-width: 100px; + } + + &.primary { + font-weight: bold; + } + + > .ripples { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 6px; + overflow: hidden; + + ::v-deep div { + position: absolute; + width: 2px; + height: 2px; + border-radius: 100%; + background: rgba(0, 0, 0, 0.1); + opacity: 1; + transform: scale(1); + transition: all 0.5s cubic-bezier(0,.5,0,1); + } + } + + &.primary > .ripples ::v-deep div { + background: rgba(0, 0, 0, 0.15); + } + + > .content { + position: relative; + z-index: 1; + } +} +</style> diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue new file mode 100644 index 0000000000..19820a307d --- /dev/null +++ b/src/client/components/ui/container.vue @@ -0,0 +1,104 @@ +<template> +<div class="ukygtjoj _panel" :class="{ naked, hideHeader: !showHeader }"> + <header v-if="showHeader"> + <div class="title"><slot name="header"></slot></div> + <slot name="func"></slot> + <button class="_button" v-if="bodyTogglable" @click="() => showBody = !showBody"> + <template v-if="showBody"><fa :icon="faAngleUp"/></template> + <template v-else><fa :icon="faAngleDown"/></template> + </button> + </header> + <div v-show="showBody"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + showHeader: { + type: Boolean, + required: false, + default: true + }, + naked: { + type: Boolean, + required: false, + default: false + }, + bodyTogglable: { + type: Boolean, + required: false, + default: false + }, + expanded: { + type: Boolean, + required: false, + default: true + }, + }, + data() { + return { + showBody: this.expanded, + faAngleUp, faAngleDown + }; + }, + methods: { + toggleContent(show: boolean) { + if (!this.bodyTogglable) return; + this.showBody = show; + } + } +}); +</script> + +<style lang="scss" scoped> +.ukygtjoj { + position: relative; + overflow: hidden; + + & + .ukygtjoj { + margin-top: var(--margin); + } + + &.naked { + background: transparent !important; + box-shadow: none !important; + } + + > header { + position: relative; + + > .title { + margin: 0; + padding: 12px 16px; + + @media (max-width: 500px) { + padding: 8px 10px; + } + + > [data-icon] { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > button { + position: absolute; + z-index: 2; + top: 0; + right: 0; + padding: 0; + width: 42px; + height: 100%; + } + } +} +</style> diff --git a/src/client/components/ui/hr.vue b/src/client/components/ui/hr.vue new file mode 100644 index 0000000000..ae7f7dbf8e --- /dev/null +++ b/src/client/components/ui/hr.vue @@ -0,0 +1,15 @@ +<template> +<div class="evrzpitu"></div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({}); +</script> + +<style lang="scss" scoped> +.evrzpitu + margin 16px 0 + border-bottom solid var(--lineWidth) var(--faceDivider) + +</style> diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue new file mode 100644 index 0000000000..3e87fe261d --- /dev/null +++ b/src/client/components/ui/info.vue @@ -0,0 +1,55 @@ +<template> +<div class="fpezltsf" :class="{ warn }"> + <i v-if="warn"><fa :icon="faExclamationTriangle"/></i> + <i v-else><fa :icon="faInfoCircle"/></i> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faInfoCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + props: { + warn: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + faInfoCircle, faExclamationTriangle + }; + } +}); +</script> + +<style lang="scss" scoped> +.fpezltsf { + margin: 16px 0; + padding: 16px; + font-size: 90%; + background: var(--infoBg); + color: var(--infoFg); + border-radius: 5px; + + &.warn { + background: var(--infoWarnBg); + color: var(--infoWarnFg); + } + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/src/client/components/ui/input.vue b/src/client/components/ui/input.vue new file mode 100644 index 0000000000..69d842ef0f --- /dev/null +++ b/src/client/components/ui/input.vue @@ -0,0 +1,443 @@ +<template> +<div class="juejbjww" :class="{ focused, filled, inline, disabled }"> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input"> + <span class="label" ref="label"><slot></slot></span> + <span class="title" ref="title"> + <slot name="title"></slot> + <span class="warning" v-if="invalid"><fa :icon="faExclamationCircle"/>{{ $refs.input.validationMessage }}</span> + </span> + <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> + <template v-if="type != 'file'"> + <input v-if="debounce" ref="input" + v-debounce="500" + :type="type" + v-model.lazy="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="$emit('keydown', $event)" + @input="onInput" + :list="id" + > + <input v-else ref="input" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="$emit('keydown', $event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + </template> + <template v-else> + <input ref="input" + type="text" + :value="filePlaceholder" + readonly + @click="chooseFile" + > + <input ref="file" + type="file" + :value="value" + @change="onChangeFile" + > + </template> + <div class="suffix" ref="suffix"><slot name="suffix"></slot></div> + </div> + <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button> + <div class="desc"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import debounce from 'v-debounce'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; + +export default Vue.extend({ + directives: { + debounce + }, + props: { + value: { + required: false + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + debounce: { + required: false + }, + datalist: { + type: Array, + required: false, + }, + inline: { + type: Boolean, + required: false, + default: false + }, + save: { + type: Function, + required: false, + }, + }, + data() { + return { + v: this.value, + focused: false, + invalid: false, + changed: false, + id: Math.random().toString(), + faExclamationCircle + }; + }, + computed: { + filled(): boolean { + return this.v !== '' && this.v != null; + }, + filePlaceholder(): string | null { + if (this.type != 'file') return null; + if (this.v == null) return null; + + if (typeof this.v == 'string') return this.v; + + if (Array.isArray(this.v)) { + return this.v.map(file => file.name).join(', '); + } else { + return this.v.name; + } + } + }, + watch: { + value(v) { + this.v = v; + }, + v(v) { + if (this.type === 'number') { + this.$emit('input', parseInt(v, 10)); + } else { + this.$emit('input', v); + } + + this.invalid = this.$refs.input.validity.badInput; + } + }, + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$refs.input.focus(); + }); + } + + this.$nextTick(() => { + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (this.$refs.prefix) { + this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; + if (this.$refs.prefix.offsetWidth) { + this.$refs.input.style.paddingLeft = this.$refs.prefix.offsetWidth + 'px'; + } + } + if (this.$refs.suffix) { + if (this.$refs.suffix.offsetWidth) { + this.$refs.input.style.paddingRight = this.$refs.suffix.offsetWidth + 'px'; + } + } + }, 100); + + this.$once('hook:beforeDestroy', () => { + clearInterval(clock); + }); + }); + + this.$on('keydown', (e: KeyboardEvent) => { + if (e.code == 'Enter') { + this.$emit('enter'); + } + }); + }, + methods: { + focus() { + this.$refs.input.focus(); + }, + togglePassword() { + if (this.type == 'password') { + this.type = 'text' + } else { + this.type = 'password' + } + }, + chooseFile() { + this.$refs.file.click(); + }, + onChangeFile() { + this.v = Array.from((this.$refs.file as any).files); + this.$emit('input', this.v); + this.$emit('change', this.v); + }, + onInput(ev) { + this.changed = true; + this.$emit('change', ev); + } + } +}); +</script> + +<style lang="scss" scoped> +.juejbjww { + position: relative; + margin: 32px 0; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + position: relative; + + &:before { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: var(--inputBorder); + } + + &:after { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); + opacity: 0; + transform: scaleX(0.12); + transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: border opacity transform; + } + + > .label { + position: absolute; + z-index: 1; + top: 0; + left: 0; + pointer-events: none; + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-duration: 0.3s; + font-size: 16px; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(1); + } + + > .title { + position: absolute; + z-index: 1; + top: -17px; + left: 0 !important; + pointer-events: none; + font-size: 16px; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(.75); + white-space: nowrap; + width: 133%; + overflow: hidden; + text-overflow: ellipsis; + + > .warning { + margin-left: 0.5em; + color: var(--infoWarnFg); + + > svg { + margin-right: 0.1em; + } + } + } + + > input { + display: block; + width: 100%; + margin: 0; + padding: 0; + font: inherit; + font-weight: normal; + font-size: 16px; + line-height: 32px; + color: var(--inputText); + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + box-sizing: border-box; + + &[type='file'] { + display: none; + } + } + + > .prefix, + > .suffix { + display: block; + position: absolute; + z-index: 1; + top: 0; + font-size: 16px; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 4px; + } + + > .suffix { + right: 0; + padding-left: 4px; + } + } + + > .save { + margin: 6px 0 0 0; + font-size: 13px; + } + + > .desc { + margin: 6px 0 0 0; + font-size: 13px; + opacity: 0.7; + + &:empty { + display: none; + } + + * { + margin: 0; + } + } + + &.focused { + > .input { + &:after { + opacity: 1; + transform: scaleX(1); + } + + > .label { + color: var(--accent); + } + } + } + + &.focused, + &.filled { + > .input { + > .label { + top: -17px; + left: 0 !important; + transform: scale(0.75); + } + } + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } +} +</style> diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue new file mode 100644 index 0000000000..d953824e00 --- /dev/null +++ b/src/client/components/ui/pagination.vue @@ -0,0 +1,59 @@ +<template> +<sequential-entrance class="cxiknjgy" :class="{ autoMargin }"> + <slot :items="items"></slot> + <div class="empty" v-if="empty" key="_empty_"> + <slot name="empty"></slot> + </div> + <div class="more" v-if="more" key="_more_"> + <mk-button :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore()"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </mk-button> + </div> +</sequential-entrance> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import MkButton from './button.vue'; +import paging from '../../scripts/paging'; + +export default Vue.extend({ + mixins: [ + paging({}), + ], + + components: { + MkButton + }, + + props: { + pagination: { + required: true + }, + autoMargin: { + required: false, + default: true + } + }, + + data() { + return { + faSpinner + }; + }, +}); +</script> + +<style lang="scss" scoped> +.cxiknjgy { + &.autoMargin > *:not(:last-child) { + margin-bottom: 16px; + + @media (max-width: 500px) { + margin-bottom: 8px; + } + } +} +</style> diff --git a/src/client/components/ui/radio.vue b/src/client/components/ui/radio.vue new file mode 100644 index 0000000000..7659d147e6 --- /dev/null +++ b/src/client/components/ui/radio.vue @@ -0,0 +1,119 @@ +<template> +<div + class="novjtctn" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click="toggle" +> + <input type="radio" + :disabled="disabled" + > + <span class="button"> + <span></span> + </span> + <span class="label"><slot></slot></span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + model: { + prop: 'model', + event: 'change' + }, + props: { + model: { + required: false + }, + value: { + required: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.model === this.value; + } + }, + methods: { + toggle() { + this.$emit('change', this.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.novjtctn { + display: inline-block; + margin: 0 32px 0 0; + cursor: pointer; + transition: all 0.3s; + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + border-color: var(--radioActive); + + &:after { + background-color: var(--radioActive); + transform: scale(1); + opacity: 1; + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: absolute; + width: 20px; + height: 20px; + background: none; + border: solid 2px var(--inputLabel); + border-radius: 100%; + transition: inherit; + + &:after { + content: ''; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } + } + + > .label { + margin-left: 28px; + display: block; + font-size: 16px; + line-height: 20px; + cursor: pointer; + } +} +</style> diff --git a/src/client/components/ui/select.vue b/src/client/components/ui/select.vue new file mode 100644 index 0000000000..8bad7c5d65 --- /dev/null +++ b/src/client/components/ui/select.vue @@ -0,0 +1,220 @@ +<template> +<div class="eiipwacr" :class="{ focused, disabled, filled, inline }"> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input" @click="focus"> + <span class="label" ref="label"><slot name="label"></slot></span> + <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> + <select ref="input" + v-model="v" + :required="required" + :disabled="disabled" + @focus="focused = true" + @blur="focused = false" + > + <slot></slot> + </select> + <div class="suffix"><slot name="suffix"></slot></div> + </div> + <div class="text"><slot name="text"></slot></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: false + }, + required: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + focused: false + }; + }, + computed: { + v: { + get() { + return this.value; + }, + set(v) { + this.$emit('input', v); + } + }, + filled(): boolean { + return this.v != '' && this.v != null; + } + }, + mounted() { + if (this.$refs.prefix) { + this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; + } + }, + methods: { + focus() { + this.$refs.input.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.eiipwacr { + position: relative; + margin: 32px 0; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + display: flex; + + &:before { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: var(--inputBorder); + } + + &:after { + content: ''; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--accent); + opacity: 0; + transform: scaleX(0.12); + transition: border 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + will-change: border opacity transform; + } + + > .label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-duration: 0.3s; + font-size: 16px; + line-height: 32px; + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(1); + } + + > select { + display: block; + flex: 1; + width: 100%; + padding: 0; + font: inherit; + font-weight: normal; + font-size: 16px; + height: 32px; + background: var(--panel); + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + } + + > .prefix, + > .suffix { + display: block; + align-self: center; + justify-self: center; + font-size: 16px; + line-height: 32px; + color: rgba(#000, 0.54); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: block; + min-width: 16px; + } + } + + > .prefix { + padding-right: 4px; + } + + > .suffix { + padding-left: 4px; + } + } + + > .text { + margin: 6px 0; + font-size: 13px; + + &:empty { + display: none; + } + + * { + margin: 0; + } + } + + &.focused { + > .input { + &:after { + opacity: 1; + transform: scaleX(1); + } + + > .label { + color: var(--accent); + } + } + } + + &.focused, + &.filled { + > .input { + > .label { + top: -17px; + left: 0 !important; + transform: scale(0.75); + } + } + } +} +</style> diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue new file mode 100644 index 0000000000..d4680ca2ef --- /dev/null +++ b/src/client/components/ui/switch.vue @@ -0,0 +1,150 @@ +<template> +<div + class="ziffeoms" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click="toggle" +> + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button"> + <span></span> + </span> + <span class="label"> + <span :aria-hidden="!checked"><slot></slot></span> + <p :aria-hidden="!checked"> + <slot name="desc"></slot> + </p> + </span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + model: { + prop: 'value', + event: 'change' + }, + props: { + value: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.value; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('change', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ziffeoms { + position: relative; + display: flex; + margin: 32px 0; + cursor: pointer; + transition: all 0.3s; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--xxubwiul); + border-color: var(--xxubwiul); + + > * { + background-color: var(--accent); + transform: translateX(14px); + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 3px 0 0 0; + width: 34px; + height: 14px; + background: var(--nhzhphzx); + outline: none; + border-radius: 14px; + transition: inherit; + + > * { + position: absolute; + top: -3px; + left: 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 20px; + height: 20px; + background-color: #fff; + box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12); + } + } + + > .label { + margin-left: 8px; + display: block; + font-size: 16px; + cursor: pointer; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + + > p { + margin: 0; + opacity: 0.7; + font-size: 90%; + } + } +} +</style> diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue new file mode 100644 index 0000000000..7b42b78a73 --- /dev/null +++ b/src/client/components/ui/textarea.vue @@ -0,0 +1,218 @@ +<template> +<div class="adhpbeos" :class="{ focused, filled, tall, pre }"> + <div class="input"> + <span class="label" ref="label"><slot></slot></span> + <textarea ref="input" + :value="value" + :required="required" + :readonly="readonly" + :pattern="pattern" + :autocomplete="autocomplete" + @input="onInput" + @focus="focused = true" + @blur="focused = false" + ></textarea> + </div> + <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button> + <div class="desc"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + autocomplete: { + type: String, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + save: { + type: Function, + required: false, + }, + }, + data() { + return { + focused: false, + changed: false, + } + }, + computed: { + filled(): boolean { + return this.value != '' && this.value != null; + } + }, + methods: { + focus() { + this.$refs.input.focus(); + }, + onInput(ev) { + this.changed = true; + this.$emit('input', ev.target.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.adhpbeos { + margin: 42px 0 32px 0; + position: relative; + + &:last-child { + margin-bottom: 0; + } + + > .input { + position: relative; + + &:before { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: none; + border: solid 1px var(--inputBorder); + border-radius: 3px; + pointer-events: none; + } + + &:after { + content: ''; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: none; + border: solid 2px var(--accent); + border-radius: 3px; + opacity: 0; + transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; + } + + > .label { + position: absolute; + top: 6px; + left: 12px; + pointer-events: none; + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + transition-duration: 0.3s; + font-size: 16px; + line-height: 32px; + pointer-events: none; + //will-change transform + transform-origin: top left; + transform: scale(1); + } + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + padding: 12px; + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 16px; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + } + } + + > .save { + margin: 6px 0 0 0; + font-size: 13px; + } + + > .desc { + margin: 6px 0 0 0; + font-size: 13px; + opacity: 0.7; + + &:empty { + display: none; + } + + * { + margin: 0; + } + } + + &.focused { + > .input { + &:after { + opacity: 1; + } + + > .label { + color: var(--accent); + } + } + } + + &.focused, + &.filled { + > .input { + > .label { + top: -24px; + left: 0 !important; + transform: scale(0.75); + } + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } + + &.pre { + > .input { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/src/client/components/uploader.vue b/src/client/components/uploader.vue new file mode 100644 index 0000000000..14a4f845c1 --- /dev/null +++ b/src/client/components/uploader.vue @@ -0,0 +1,242 @@ +<template> +<div class="mk-uploader"> + <ol v-if="uploads.length > 0"> + <li v-for="ctx in uploads" :key="ctx.id"> + <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> + <div class="top"> + <p class="name"><fa icon="spinner" pulse/>{{ ctx.name }}</p> + <p class="status"> + <span class="initing" v-if="ctx.progress == undefined">{{ $t('waiting') }}<mk-ellipsis/></span> + <span class="kb" v-if="ctx.progress != undefined">{{ String(Math.floor(ctx.progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> + <span class="percentage" v-if="ctx.progress != undefined">{{ Math.floor((ctx.progress.value / ctx.progress.max) * 100) }}</span> + </p> + </div> + <progress v-if="ctx.progress != undefined && ctx.progress.value != ctx.progress.max" :value="ctx.progress.value" :max="ctx.progress.max"></progress> + <div class="progress initing" v-if="ctx.progress == undefined"></div> + <div class="progress waiting" v-if="ctx.progress != undefined && ctx.progress.value == ctx.progress.max"></div> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { apiUrl } from '../config'; +//import getMD5 from '../../scripts/get-md5'; + +export default Vue.extend({ + i18n, + data() { + return { + uploads: [] + }; + }, + methods: { + checkExistence(fileData: ArrayBuffer): Promise<any> { + return new Promise((resolve, reject) => { + const data = new FormData(); + data.append('md5', getMD5(fileData)); + + this.$root.api('drive/files/find-by-hash', { + md5: getMD5(fileData) + }).then(resp => { + resolve(resp.length > 0 ? resp[0] : null); + }); + }); + }, + + upload(file: File, folder: any, name?: string) { + if (folder && typeof folder == 'object') folder = folder.id; + + const id = Math.random(); + + const reader = new FileReader(); + reader.onload = (e: any) => { + const ctx = { + id: id, + name: name || file.name || 'untitled', + progress: undefined, + img: window.URL.createObjectURL(file) + }; + + this.uploads.push(ctx); + this.$emit('change', this.uploads); + + const data = new FormData(); + data.append('i', this.$store.state.i.token); + data.append('force', 'true'); + data.append('file', file); + + if (folder) data.append('folderId', folder); + if (name) data.append('name', name); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = (e: any) => { + const driveFile = JSON.parse(e.target.response); + + this.$emit('uploaded', driveFile); + + this.uploads = this.uploads.filter(x => x.id != id); + this.$emit('change', this.uploads); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + if (ctx.progress == undefined) ctx.progress = {}; + ctx.progress.max = e.total; + ctx.progress.value = e.loaded; + } + }; + + xhr.send(data); + } + reader.readAsArrayBuffer(file); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-uploader { + overflow: auto; +} +.mk-uploader:empty { + display: none; +} +.mk-uploader > ol { + display: block; + margin: 0; + padding: 0; + list-style: none; +} +.mk-uploader > ol > li { + display: grid; + margin: 8px 0 0 0; + padding: 0; + height: 36px; + width: 100%; + box-shadow: 0 -1px 0 var(--accentAlpha01); + border-top: solid 8px transparent; + grid-template-columns: 36px calc(100% - 44px); + grid-template-rows: 1fr 8px; + column-gap: 8px; + box-sizing: content-box; +} +.mk-uploader > ol > li:first-child { + margin: 0; + box-shadow: none; + border-top: none; +} +.mk-uploader > ol > li > .img { + display: block; + background-size: cover; + background-position: center center; + grid-column: 1/2; + grid-row: 1/3; +} +.mk-uploader > ol > li > .top { + display: flex; + grid-column: 2/3; + grid-row: 1/2; +} +.mk-uploader > ol > li > .top > .name { + display: block; + padding: 0 8px 0 0; + margin: 0; + font-size: 0.8em; + color: var(--accentAlpha07); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex-shrink: 1; +} +.mk-uploader > ol > li > .top > .name > [data-icon] { + margin-right: 4px; +} +.mk-uploader > ol > li > .top > .status { + display: block; + margin: 0 0 0 auto; + padding: 0; + font-size: 0.8em; + flex-shrink: 0; +} +.mk-uploader > ol > li > .top > .status > .initing { + color: var(--accentAlpha05); +} +.mk-uploader > ol > li > .top > .status > .kb { + color: var(--accentAlpha05); +} +.mk-uploader > ol > li > .top > .status > .percentage { + display: inline-block; + width: 48px; + text-align: right; + color: var(--accentAlpha07); +} +.mk-uploader > ol > li > .top > .status > .percentage:after { + content: '%'; +} +.mk-uploader > ol > li > progress { + display: block; + background: transparent; + border: none; + border-radius: 4px; + overflow: hidden; + grid-column: 2/3; + grid-row: 2/3; + z-index: 2; +} +.mk-uploader > ol > li > progress::-webkit-progress-value { + background: var(--accent); +} +.mk-uploader > ol > li > progress::-webkit-progress-bar { + background: var(--accentAlpha01); +} +.mk-uploader > ol > li > .progress { + display: block; + border: none; + border-radius: 4px; + background: linear-gradient(45deg, var(--accentLighten30) 25%, var(--accent) 25%, var(--accent) 50%, var(--accentLighten30) 50%, var(--accentLighten30) 75%, var(--accent) 75%, var(--accent)); + background-size: 32px 32px; + animation: bg 1.5s linear infinite; + grid-column: 2/3; + grid-row: 2/3; + z-index: 1; +} +.mk-uploader > ol > li > .progress.initing { + opacity: 0.3; +} +@-moz-keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +@-webkit-keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +@-o-keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +@keyframes bg { + from { + background-position: 0 0; + } + to { + background-position: -64px 32px; + } +} +</style> diff --git a/src/client/components/url-preview.vue b/src/client/components/url-preview.vue new file mode 100644 index 0000000000..f2ef1f1ba3 --- /dev/null +++ b/src/client/components/url-preview.vue @@ -0,0 +1,331 @@ +<template> +<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> + <button class="disablePlayer" @click="playerEnabled = false" :title="$t('disable-player')"><fa icon="times"/></button> + <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> +</div> +<div v-else-if="tweetUrl && detail" class="twitter"> + <blockquote ref="tweet" class="twitter-tweet" :data-theme="$store.state.device.darkmode ? 'dark' : null"> + <a :href="url"></a> + </blockquote> +</div> +<div v-else class="mk-url-preview" v-size="[{ max: 400 }, { max: 350 }]"> + <transition name="zoom" mode="out-in"> + <component :is="hasRoute ? 'router-link' : 'a'" :class="{ compact }" :[attr]="hasRoute ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching"> + <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"> + <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$t('enable-player')"><fa :icon="faPlayCircle"/></button> + </div> + <article> + <header> + <h1 :title="title">{{ title }}</h1> + </header> + <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> + <footer> + <img class="icon" v-if="icon" :src="icon"/> + <p :title="sitename">{{ sitename }}</p> + </footer> + </article> + </component> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPlayCircle } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import { url as local, lang } from '../config'; + +export default Vue.extend({ + i18n, + + props: { + url: { + type: String, + require: true + }, + + detail: { + type: Boolean, + required: false, + default: false + }, + + compact: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + const isSelf = this.url.startsWith(local); + const hasRoute = + (this.url.substr(local.length) === '/') || + this.url.substr(local.length).startsWith('/@') || + this.url.substr(local.length).startsWith('/notes/') || + this.url.substr(local.length).startsWith('/tags/'); + return { + local, + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null, + player: { + url: null, + width: null, + height: null + }, + tweetUrl: null, + playerEnabled: false, + self: isSelf, + hasRoute: hasRoute, + attr: hasRoute ? 'to' : 'href', + target: hasRoute ? null : '_blank', + faPlayCircle + }; + }, + + created() { + const requestUrl = new URL(this.url); + + if (this.detail && requestUrl.hostname == 'twitter.com' && /^\/.+\/status(es)?\/\d+/.test(requestUrl.pathname)) { + this.tweetUrl = requestUrl; + const twttr = (window as any).twttr || {}; + const loadTweet = () => twttr.widgets.load(this.$refs.tweet); + + if (twttr.widgets) { + Vue.nextTick(loadTweet); + } else { + const wjsId = 'twitter-wjs'; + if (!document.getElementById(wjsId)) { + const head = document.getElementsByTagName('head')[0]; + const script = document.createElement('script'); + script.setAttribute('id', wjsId); + script.setAttribute('src', 'https://platform.twitter.com/widgets.js'); + head.appendChild(script); + } + twttr.ready = loadTweet; + (window as any).twttr = twttr; + } + return; + } + + if (requestUrl.hostname === 'music.youtube.com') { + requestUrl.hostname = 'youtube.com'; + } + + const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP'); + + requestUrl.hash = ''; + + fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { + res.json().then(info => { + if (info.url == null) return; + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + this.fetching = false; + this.player = info.player; + }) + }); + } +}); +</script> + +<style lang="scss" scoped> +.player { + position: relative; + width: 100%; + + > button { + position: absolute; + top: -1.5em; + right: 0; + font-size: 1em; + width: 1.5em; + height: 1.5em; + padding: 0; + margin: 0; + color: var(--fg); + background: rgba(128, 128, 128, 0.2); + opacity: 0.7; + + &:hover { + opacity: 0.9; + } + } + + > iframe { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } +} + +.mk-url-preview { + &.max-width_400px { + > a { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + } + + &.max-width_350px { + > a { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + + &.compact { + > .thumbnail { + position: absolute; + width: 56px; + height: 100%; + } + + > article { + left: 56px; + width: calc(100% - 56px); + padding: 4px; + + > header { + margin-bottom: 2px; + } + + > footer { + margin-top: 2px; + } + } + } + } + } + + > a { + position: relative; + display: block; + font-size: 14px; + box-shadow: 0 1px 4px var(--tyvedwbe); + border-radius: 4px; + overflow: hidden; + + &:hover { + text-decoration: none; + border-color: rgba(0, 0, 0, 0.2); + + > article > header > h1 { + text-decoration: underline; + } + } + + > .thumbnail { + position: absolute; + width: 100px; + height: 100%; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + position: relative; + box-sizing: border-box; + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + } + } + + > p { + margin: 0; + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + &.compact { + > article { + > header h1, p, footer { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + } +} +</style> diff --git a/src/client/components/url.vue b/src/client/components/url.vue new file mode 100644 index 0000000000..082e744001 --- /dev/null +++ b/src/client/components/url.vue @@ -0,0 +1,95 @@ +<template> +<component :is="hasRoute ? 'router-link' : 'a'" class="mk-url" :[attr]="hasRoute ? url.substr(local.length) : url" :rel="rel" :target="target"> + <template v-if="!self"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span class="port" v-if="port != ''">:{{ port }}</span> + </template> + <template v-if="pathname === '/' && self"> + <span class="self">{{ hostname }}</span> + </template> + <span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + <fa :icon="faExternalLinkSquareAlt" v-if="target === '_blank'" class="icon"/> +</component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons'; +import { toUnicode as decodePunycode } from 'punycode'; +import { url as local } from '../config'; + +export default Vue.extend({ + props: ['url', 'rel'], + data() { + const isSelf = this.url.startsWith(local); + const hasRoute = isSelf && ( + (this.url.substr(local.length) === '/') || + this.url.substr(local.length).startsWith('/@') || + this.url.substr(local.length).startsWith('/notes/') || + this.url.substr(local.length).startsWith('/tags/')); + return { + local, + schema: null, + hostname: null, + port: null, + pathname: null, + query: null, + hash: null, + self: isSelf, + hasRoute: hasRoute, + attr: hasRoute ? 'to' : 'href', + target: hasRoute ? null : '_blank', + faExternalLinkSquareAlt + }; + }, + created() { + const url = new URL(this.url); + this.schema = url.protocol; + this.hostname = decodePunycode(url.hostname); + this.port = url.port; + this.pathname = decodeURIComponent(url.pathname); + this.query = decodeURIComponent(url.search); + this.hash = decodeURIComponent(url.hash); + } +}); +</script> + +<style lang="scss" scoped> +.mk-url { + word-break: break-all; + + > .icon { + padding-left: 2px; + font-size: .9em; + font-weight: 400; + font-style: normal; + } + + > .self { + font-weight: bold; + } + + > .schema { + opacity: 0.5; + } + + > .hostname { + font-weight: bold; + } + + > .pathname { + opacity: 0.8; + } + + > .query { + opacity: 0.5; + } + + > .hash { + font-style: italic; + } +} +</style> diff --git a/src/client/components/user-list.vue b/src/client/components/user-list.vue new file mode 100644 index 0000000000..14a96f3c6f --- /dev/null +++ b/src/client/components/user-list.vue @@ -0,0 +1,148 @@ +<template> +<mk-container :body-togglable="true" :expanded="expanded"> + <template #header><slot></slot></template> + + <mk-error v-if="error" @retry="init()"/> + + <div class="efvhhmdq"> + <div class="no-users" v-if="empty"> + <p>{{ $t('no-users') }}</p> + </div> + <div class="user" v-for="user in users" :key="user.id"> + <mk-avatar class="avatar" :user="user"/> + <div class="body"> + <div class="name"> + <router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link> + <span class="username"><mk-acct :user="user"/></span> + </div> + <div class="description"> + <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + <span v-else class="empty">{{ $t('noAccountDescription') }}</span> + </div> + </div> + <x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/> + </div> + <button class="more" :class="{ fetching: moreFetching }" v-if="more" @click="fetchMore()" :disabled="moreFetching"> + <template v-if="moreFetching"><fa icon="spinner" pulse fixed-width/></template>{{ moreFetching ? $t('@.loading') : $t('@.load-more') }} + </button> + </div> +</mk-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import MkContainer from './ui/container.vue'; +import XFollowButton from './follow-button.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkContainer, + XFollowButton, + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + extract: { + required: false + }, + expanded: { + type: Boolean, + default: true + }, + }, + + computed: { + users() { + return this.extract ? this.extract(this.items) : this.items; + } + } +}); +</script> + +<style lang="scss" scoped> +.efvhhmdq { + > .no-users { + text-align: center; + } + + > .user { + position: relative; + display: flex; + padding: 16px; + border-bottom: solid 1px var(--divider); + + &:last-child { + border-bottom: none; + } + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 42px; + height: 42px; + border-radius: 8px; + } + + > .body { + flex: 1; + + > .name { + font-weight: bold; + + > .name { + margin-right: 8px; + } + + > .username { + opacity: 0.7; + } + } + + > .description { + font-size: 90%; + + > .empty { + opacity: 0.7; + } + } + } + + > .koudoku-button { + flex-shrink: 0; + } + } + + > .more { + display: block; + width: 100%; + padding: 16px; + + &:hover { + background: rgba(#000, 0.025); + } + + &:active { + background: rgba(#000, 0.05); + } + + &.fetching { + cursor: wait; + } + + > [data-icon] { + margin-right: 4px; + } + } +} +</style> diff --git a/src/client/components/user-menu.vue b/src/client/components/user-menu.vue new file mode 100644 index 0000000000..6e3280031c --- /dev/null +++ b/src/client/components/user-menu.vue @@ -0,0 +1,188 @@ +<template> +<x-menu :source="source" :items="items" @closed="$emit('closed')"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments } from '@fortawesome/free-solid-svg-icons'; +import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import XMenu from './menu.vue'; +import copyToClipboard from '../scripts/copy-to-clipboard'; +import { host } from '../config'; +import getAcct from '../../misc/acct/render'; + +export default Vue.extend({ + i18n, + + components: { + XMenu + }, + + props: ['user', 'source'], + + data() { + let menu = [{ + icon: faAt, + text: this.$t('copyUsername'), + action: () => { + copyToClipboard(`@${this.user.username}@${this.user.host || host}`); + } + }, { + icon: faEnvelope, + text: this.$t('sendMessage'), + action: () => { + this.$root.post({ specified: this.user }); + } + }, this.$store.state.i.id != this.user.id ? { + type: 'link', + to: `/my/messaging/${getAcct(this.user)}`, + icon: faComments, + text: this.$t('startMessaging'), + } : undefined, null, { + icon: faListUl, + text: this.$t('addToList'), + action: this.pushList + }] as any; + + if (this.$store.getters.isSignedIn && this.$store.state.i.id != this.user.id) { + menu = menu.concat([null, { + icon: this.user.isMuted ? faEye : faEyeSlash, + text: this.user.isMuted ? this.$t('unmute') : this.$t('mute'), + action: this.toggleMute + }, { + icon: faBan, + text: this.user.isBlocking ? this.$t('unblock') : this.$t('block'), + action: this.toggleBlock + }]); + + if (this.$store.state.i.isAdmin) { + menu = menu.concat([null, { + icon: faSnowflake, + text: this.user.isSuspended ? this.$t('unsuspend') : this.$t('suspend'), + action: this.toggleSuspend + }]); + } + } + + if (this.$store.getters.isSignedIn && this.$store.state.i.id === this.user.id) { + menu = menu.concat([null, { + icon: faPencilAlt, + text: this.$t('editProfile'), + action: () => { + this.$router.push('/my/settings'); + } + }]); + } + + return { + items: menu + }; + }, + + methods: { + async pushList() { + const t = this.$t('selectList'); // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await this.$root.api('users/lists/list'); + if (lists.length === 0) { + this.$root.dialog({ + type: 'error', + text: this.$t('youHaveNoLists') + }); + return; + } + const { canceled, result: listId } = await this.$root.dialog({ + type: null, + title: t, + select: { + items: lists.map(list => ({ + value: list.id, text: list.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + this.$root.api('users/lists/push', { + listId: listId, + userId: this.user.id + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async toggleMute() { + this.$root.api(this.user.isMuted ? 'mute/delete' : 'mute/create', { + userId: this.user.id + }).then(() => { + this.user.isMuted = !this.user.isMuted; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async toggleBlock() { + if (!await this.getConfirmed(this.user.isBlocking ? this.$t('unblockConfirm') : this.$t('blockConfirm'))) return; + + this.$root.api(this.user.isBlocking ? 'blocking/delete' : 'blocking/create', { + userId: this.user.id + }).then(() => { + this.user.isBlocking = !this.user.isBlocking; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async toggleSuspend() { + if (!await this.getConfirmed(this.$t(this.user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + this.$root.api(this.user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: this.user.id + }).then(() => { + this.user.isSuspended = !this.user.isSuspended; + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }, e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async getConfirmed(text: string): Promise<Boolean> { + const confirm = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + title: 'confirm', + text, + }); + + return !confirm.canceled; + }, + } +}); +</script> diff --git a/src/client/components/user-moderate-dialog.vue b/src/client/components/user-moderate-dialog.vue new file mode 100644 index 0000000000..894db5384e --- /dev/null +++ b/src/client/components/user-moderate-dialog.vue @@ -0,0 +1,108 @@ +<template> +<x-window @closed="() => { $emit('closed'); destroyDom(); }" :avatar="user"> + <template #header><mk-user-name :user="user"/></template> + <div class="vrcsvlkm"> + <mk-button @click="changePassword()">{{ $t('changePassword') }}</mk-button> + <mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch> + <mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import MkButton from './ui/button.vue'; +import MkSwitch from './ui/switch.vue'; +import XWindow from './window.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkSwitch, + XWindow, + }, + + props: { + user: { + type: Object, + required: true + } + }, + + data() { + return { + silenced: this.user.isSilenced, + suspended: this.user.isSuspended, + }; + }, + + methods: { + async changePassword() { + const { canceled: canceled, result: newPassword } = await this.$root.dialog({ + title: this.$t('newPassword'), + input: { + type: 'password' + } + }); + if (canceled) return; + + const dialog = this.$root.dialog({ + type: 'waiting', + iconOnly: true + }); + + this.$root.api('admin/change-password', { + userId: this.user.id, + newPassword + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }).finally(() => { + dialog.close(); + }); + }, + + async toggleSilence() { + const confirm = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + text: this.silenced ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'), + }); + if (confirm.canceled) { + this.silenced = !this.silenced; + } else { + this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); + } + }, + + async toggleSuspend() { + const confirm = await this.$root.dialog({ + type: 'warning', + showCancelButton: true, + text: this.suspended ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'), + }); + if (confirm.canceled) { + this.suspended = !this.suspended; + } else { + this.$root.api(this.silenced ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.vrcsvlkm { + +} +</style> diff --git a/src/client/components/user-name.vue b/src/client/components/user-name.vue new file mode 100644 index 0000000000..425cb587c4 --- /dev/null +++ b/src/client/components/user-name.vue @@ -0,0 +1,20 @@ +<template> +<mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + }, + nowrap: { + type: Boolean, + default: true + }, + } +}); +</script> diff --git a/src/client/components/user-preview.vue b/src/client/components/user-preview.vue new file mode 100644 index 0000000000..f20335d02b --- /dev/null +++ b/src/client/components/user-preview.vue @@ -0,0 +1,181 @@ +<template> +<transition name="popup" appear @after-leave="() => { $emit('closed'); destroyDom(); }"> + <div v-if="show" class="fxxzrfni _panel" ref="content" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> + <div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl})` : ''"></div> + <mk-avatar class="avatar" :user="u" :disable-preview="true"/> + <div class="title"> + <router-link class="name" :to="u | userPage"><mk-user-name :user="u" :nowrap="false"/></router-link> + <p class="username"><mk-acct :user="u"/></p> + </div> + <div class="description"> + <mfm v-if="u.description" :text="u.description" :author="u" :i="$store.state.i" :custom-emojis="u.emojis"/> + </div> + <div class="status"> + <div> + <p>{{ $t('notes') }}</p><span>{{ u.notesCount }}</span> + </div> + <div> + <p>{{ $t('following') }}</p><span>{{ u.followingCount }}</span> + </div> + <div> + <p>{{ $t('followers') }}</p><span>{{ u.followersCount }}</span> + </div> + </div> + <x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && u.id != $store.state.i.id" :user="u" mini/> + </div> +</transition> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import parseAcct from '../../misc/acct/parse'; +import XFollowButton from './follow-button.vue'; + +export default Vue.extend({ + i18n, + + components: { + XFollowButton + }, + + props: { + user: { + type: [Object, String], + required: true + }, + source: { + required: true + } + }, + + data() { + return { + u: null, + show: false, + top: 0, + left: 0, + }; + }, + + mounted() { + if (typeof this.user == 'object') { + this.u = this.user; + this.show = true; + } else { + const query = this.user.startsWith('@') ? + parseAcct(this.user.substr(1)) : + { userId: this.user }; + + this.$root.api('users/show', query).then(user => { + this.u = user; + this.show = true; + }); + } + + const rect = this.source.getBoundingClientRect(); + const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; + const y = rect.top + this.source.offsetHeight + window.pageYOffset; + + this.top = y; + this.left = x; + }, + + methods: { + close() { + this.show = false; + (this.$refs.content as any).style.pointerEvents = 'none'; + } + } +}); +</script> + +<style lang="scss" scoped> +.popup-enter-active, .popup-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.popup-enter, .popup-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.fxxzrfni { + position: absolute; + z-index: 11000; + width: 300px; + overflow: hidden; + + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 3px var(--face); + border-radius: 8px; + } + + > .title { + display: block; + padding: 8px 0 8px 82px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--text); + opacity: 0.7; + } + } + + > .description { + padding: 0 16px; + font-size: 0.8em; + color: var(--text); + } + + > .status { + padding: 8px 16px; + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--text); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/src/client/components/user-select.vue b/src/client/components/user-select.vue new file mode 100644 index 0000000000..a82626652d --- /dev/null +++ b/src/client/components/user-select.vue @@ -0,0 +1,152 @@ +<template> +<x-window ref="window" @closed="() => { $emit('closed'); destroyDom(); }" :with-ok-button="true" :ok-button-disabled="selected == null" @ok="ok()"> + <template #header>{{ $t('selectUser') }}</template> + <div class="tbhwbxda"> + <div class="inputs"> + <mk-input v-model="username" class="input" @input="search" ref="username"><span>{{ $t('username') }}</span><template #prefix>@</template></mk-input> + <mk-input v-model="host" class="input" @input="search"><span>{{ $t('host') }}</span><template #prefix>@</template></mk-input> + </div> + <div class="users"> + <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <mk-avatar :user="user" class="avatar" :disable-link="true"/> + <div class="body"> + <mk-user-name :user="user" class="name"/> + <mk-acct :user="user" class="acct"/> + </div> + </div> + </div> + </div> +</x-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../i18n'; +import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; +import MkInput from './ui/input.vue'; +import XWindow from './window.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkInput, + XWindow, + }, + + props: { + }, + + data() { + return { + username: '', + host: '', + users: [], + selected: null, + faTimes, faCheck + }; + }, + + mounted() { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + }, + + methods: { + search() { + if (this.username == '' && this.host == '') { + this.users = []; + return; + } + this.$root.api('users/search-by-username-and-host', { + username: this.username, + host: this.host, + limit: 10, + detail: false + }).then(users => { + this.users = users; + }); + }, + + focus() { + this.$refs.username.focus(); + }, + + close() { + this.$refs.window.close(); + }, + + ok() { + this.$emit('selected', this.selected); + this.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.tbhwbxda { + display: flex; + flex-direction: column; + overflow: auto; + height: 100%; + + > .inputs { + margin-top: 16px; + + > .input { + display: inline-block; + width: 50%; + margin: 0; + } + } + + > .users { + flex: 1; + overflow: auto; + + > .user { + display: flex; + align-items: center; + padding: 8px 16px; + font-size: 14px; + + &:hover { + background: var(--bwqtlupy); + } + + &.selected { + background: var(--accent); + color: #fff; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + min-width: 0; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } +} +</style> diff --git a/src/client/components/users-dialog.vue b/src/client/components/users-dialog.vue new file mode 100644 index 0000000000..19310bc4e1 --- /dev/null +++ b/src/client/components/users-dialog.vue @@ -0,0 +1,161 @@ +<template> +<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }"> + <div class="mk-users-dialog"> + <div class="header"> + <span>{{ title }}</span> + <button class="_button" @click="close()"><fa :icon="faTimes"/></button> + </div> + + <sequential-entrance class="users"> + <router-link v-for="(item, i) in items" class="user" :key="item.id" :data-index="i" :to="extract ? extract(item) : item | userPage"> + <mk-avatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true"/> + <div class="body"> + <mk-user-name :user="extract ? extract(item) : item" class="name"/> + <mk-acct :user="extract ? extract(item) : item" class="acct"/> + </div> + </router-link> + </sequential-entrance> + + <button class="more _button" v-if="more" @click="fetchMore" :disabled="moreFetching"> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><fa :icon="faSpinner" pulse fixed-width/></template> + </button> + + <p class="empty" v-if="empty">{{ $t('noUsers') }}</p> + + <mk-error v-if="error" @retry="init()"/> + </div> +</x-modal> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import paging from '../scripts/paging'; +import XModal from './modal.vue'; + +export default Vue.extend({ + i18n, + + components: { + XModal, + }, + + mixins: [ + paging({}), + ], + + props: { + title: { + required: true + }, + pagination: { + required: true + }, + extract: { + required: false + } + }, + + data() { + return { + faTimes + }; + }, + + methods: { + close() { + this.$refs.modal.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-users-dialog { + width: 350px; + height: 350px; + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + + > .header { + display: flex; + flex-shrink: 0; + + > button { + height: 58px; + width: 58px; + + @media (max-width: 500px) { + height: 42px; + width: 42px; + } + } + + > span { + flex: 1; + line-height: 58px; + padding-left: 32px; + font-weight: bold; + + @media (max-width: 500px) { + line-height: 42px; + padding-left: 16px; + } + } + } + + > .users { + flex: 1; + overflow: auto; + + &:empty { + display: none; + } + + > .user { + display: flex; + align-items: center; + font-size: 14px; + padding: 8px 32px; + + @media (max-width: 500px) { + padding: 8px 16px; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + overflow: hidden; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + + > .empty { + text-align: center; + opacity: 0.5; + } +} +</style> diff --git a/src/client/components/visibility-chooser.vue b/src/client/components/visibility-chooser.vue new file mode 100644 index 0000000000..aa422b27dc --- /dev/null +++ b/src/client/components/visibility-chooser.vue @@ -0,0 +1,127 @@ +<template> +<x-popup :source="source" ref="popup" @closed="() => { $emit('closed'); destroyDom(); }"> + <sequential-entrance class="gqyayizv" :delay="30"> + <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="0" key="0"> + <div><fa :icon="faGlobe"/></div> + <div> + <span>{{ $t('_visibility.public') }}</span> + <span>{{ $t('_visibility.publicDescription') }}</span> + </div> + </button> + <button class="_button" @click="choose('home')" :class="{ active: v == 'home' }" data-index="1" key="1"> + <div><fa :icon="faHome"/></div> + <div> + <span>{{ $t('_visibility.home') }}</span> + <span>{{ $t('_visibility.homeDescription') }}</span> + </div> + </button> + <button class="_button" @click="choose('followers')" :class="{ active: v == 'followers' }" data-index="2" key="2"> + <div><fa :icon="faUnlock"/></div> + <div> + <span>{{ $t('_visibility.followers') }}</span> + <span>{{ $t('_visibility.followersDescription') }}</span> + </div> + </button> + <button class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="3" key="3"> + <div><fa :icon="faEnvelope"/></div> + <div> + <span>{{ $t('_visibility.specified') }}</span> + <span>{{ $t('_visibility.specifiedDescription') }}</span> + </div> + </button> + </sequential-entrance> +</x-popup> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faGlobe, faUnlock, faHome } from '@fortawesome/free-solid-svg-icons'; +import { faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import i18n from '../i18n'; +import XPopup from './popup.vue'; + +export default Vue.extend({ + i18n, + components: { + XPopup + }, + props: { + source: { + required: true + }, + currentVisibility: { + type: String, + required: false + } + }, + data() { + return { + v: this.$store.state.settings.rememberNoteVisibility ? (this.$store.state.device.visibility || this.$store.state.settings.defaultNoteVisibility) : (this.currentVisibility || this.$store.state.settings.defaultNoteVisibility), + faGlobe, faUnlock, faEnvelope, faHome + } + }, + methods: { + choose(visibility) { + if (this.$store.state.settings.rememberNoteVisibility) { + this.$store.commit('device/setVisibility', visibility); + } + this.$emit('chosen', visibility); + this.destroyDom(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.gqyayizv { + width: 240px; + padding: 8px 0; + + > button { + display: flex; + padding: 8px 14px; + font-size: 12px; + text-align: left; + width: 100%; + box-sizing: border-box; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: rgba(0, 0, 0, 0.1); + } + + &.active { + color: #fff; + background: var(--accent); + } + + > *:first-child { + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + + > *:last-child { + flex: 1 1 auto; + + > span:first-child { + display: block; + font-weight: bold; + } + + > span:last-child:not(:first-child) { + opacity: 0.6; + } + } + } +} +</style> diff --git a/src/client/components/window.vue b/src/client/components/window.vue new file mode 100644 index 0000000000..bfdabee059 --- /dev/null +++ b/src/client/components/window.vue @@ -0,0 +1,155 @@ +<template> +<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }"> + <div class="ebkgoccj" :class="{ noPadding }" @keydown="onKeydown"> + <div class="header"> + <button class="_button" v-if="withOkButton" @click="close()"><fa :icon="faTimes"/></button> + <span class="title"> + <mk-avatar :user="avatar" v-if="avatar" class="avatar"/> + <slot name="header"></slot> + </span> + <button class="_button" v-if="!withOkButton" @click="close()"><fa :icon="faTimes"/></button> + <button class="_button" v-if="withOkButton" @click="() => { $emit('ok'); close(); }" :disabled="okButtonDisabled"><fa :icon="faCheck"/></button> + </div> + <div class="body"> + <slot></slot> + </div> + </div> +</x-modal> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faTimes, faCheck } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../i18n'; +import XModal from './modal.vue'; + +export default Vue.extend({ + i18n, + + components: { + XModal, + }, + + props: { + avatar: { + type: Object, + required: false + }, + withOkButton: { + type: Boolean, + required: false, + default: false + }, + okButtonDisabled: { + type: Boolean, + required: false, + default: false + }, + noPadding: { + type: Boolean, + required: false, + default: false + } + }, + + data() { + return { + faTimes, faCheck + }; + }, + + methods: { + close() { + this.$refs.modal.close(); + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.ebkgoccj { + width: 400px; + height: 400px; + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + + @media (max-width: 500px) { + width: 350px; + height: 350px; + } + + > .header { + $height: 58px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + + > button { + height: $height; + width: $height; + + @media (max-width: 500px) { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + line-height: $height; + padding-left: 32px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + + @media (max-width: 500px) { + line-height: $height-narrow; + padding-left: 16px; + } + + > .avatar { + $size: 32px; + height: $size; + width: $size; + margin: (($height - $size) / 2) 8px (($height - $size) / 2) 0; + + @media (max-width: 500px) { + $size: 24px; + height: $size; + width: $size; + margin: (($height-narrow - $size) / 2) 8px (($height-narrow - $size) / 2) 0; + } + } + } + + > button + .title { + padding-left: 0; + } + } + + > .body { + overflow: auto; + } + + &:not(.noPadding) > .body { + padding: 0 32px 32px 32px; + + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } + } +} +</style> |