summaryrefslogtreecommitdiff
path: root/src/client/components
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-01-30 04:37:25 +0900
committerGitHub <noreply@github.com>2020-01-30 04:37:25 +0900
commitf6154dc0af1a0d65819e87240f4385f9573095cb (patch)
tree699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/components
parentAdd Event activity-type support (#5785) (diff)
downloadsharkey-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')
-rw-r--r--src/client/components/acct.vue29
-rw-r--r--src/client/components/autocomplete.vue443
-rw-r--r--src/client/components/avatar.vue116
-rw-r--r--src/client/components/avatars.vue27
-rw-r--r--src/client/components/code-core.vue34
-rw-r--r--src/client/components/code.vue26
-rw-r--r--src/client/components/cw-button.vue73
-rw-r--r--src/client/components/date-separated-list.vue94
-rw-r--r--src/client/components/dialog.vue320
-rw-r--r--src/client/components/drive-file-thumbnail.vue188
-rw-r--r--src/client/components/drive-window.vue53
-rw-r--r--src/client/components/drive.file.vue368
-rw-r--r--src/client/components/drive.folder.vue281
-rw-r--r--src/client/components/drive.nav-folder.vue139
-rw-r--r--src/client/components/drive.vue664
-rw-r--r--src/client/components/ellipsis.vue34
-rw-r--r--src/client/components/emoji-picker.vue268
-rw-r--r--src/client/components/emoji.vue132
-rw-r--r--src/client/components/error.vue42
-rw-r--r--src/client/components/file-type-icon.vue29
-rw-r--r--src/client/components/follow-button.vue162
-rw-r--r--src/client/components/formula-core.vue33
-rw-r--r--src/client/components/formula.vue22
-rw-r--r--src/client/components/google.vue71
-rw-r--r--src/client/components/index.ts25
-rw-r--r--src/client/components/loading.vue30
-rw-r--r--src/client/components/media-banner.vue109
-rw-r--r--src/client/components/media-image.vue113
-rw-r--r--src/client/components/media-list.vue130
-rw-r--r--src/client/components/media-video.vue79
-rw-r--r--src/client/components/mention.vue82
-rw-r--r--src/client/components/menu.vue165
-rw-r--r--src/client/components/mfm.ts299
-rw-r--r--src/client/components/misskey-flavored-markdown.vue35
-rw-r--r--src/client/components/modal.vue84
-rw-r--r--src/client/components/note-header.vue99
-rw-r--r--src/client/components/note-menu.vue199
-rw-r--r--src/client/components/note-preview.vue121
-rw-r--r--src/client/components/note.sub.vue108
-rw-r--r--src/client/components/note.vue729
-rw-r--r--src/client/components/notes.vue144
-rw-r--r--src/client/components/notification.vue219
-rw-r--r--src/client/components/notifications.vue136
-rw-r--r--src/client/components/page-preview.vue163
-rw-r--r--src/client/components/page/page.block.vue40
-rw-r--r--src/client/components/page/page.button.vue59
-rw-r--r--src/client/components/page/page.counter.vue49
-rw-r--r--src/client/components/page/page.if.vue29
-rw-r--r--src/client/components/page/page.image.vue36
-rw-r--r--src/client/components/page/page.number-input.vue44
-rw-r--r--src/client/components/page/page.post.vue75
-rw-r--r--src/client/components/page/page.radio-button.vue36
-rw-r--r--src/client/components/page/page.section.vue58
-rw-r--r--src/client/components/page/page.switch.vue46
-rw-r--r--src/client/components/page/page.text-input.vue44
-rw-r--r--src/client/components/page/page.text.vue65
-rw-r--r--src/client/components/page/page.textarea-input.vue35
-rw-r--r--src/client/components/page/page.textarea.vue35
-rw-r--r--src/client/components/page/page.vue230
-rw-r--r--src/client/components/poll-editor.vue218
-rw-r--r--src/client/components/poll.vue174
-rw-r--r--src/client/components/popup.vue147
-rw-r--r--src/client/components/post-form-attaches.vue158
-rw-r--r--src/client/components/post-form-dialog.vue157
-rw-r--r--src/client/components/post-form.vue747
-rw-r--r--src/client/components/reaction-icon.vue32
-rw-r--r--src/client/components/reaction-picker.vue229
-rw-r--r--src/client/components/reactions-viewer.details.vue117
-rw-r--r--src/client/components/reactions-viewer.reaction.vue167
-rw-r--r--src/client/components/reactions-viewer.vue48
-rw-r--r--src/client/components/renote-picker.vue94
-rw-r--r--src/client/components/sequential-entrance.vue63
-rw-r--r--src/client/components/signin-dialog.vue37
-rw-r--r--src/client/components/signin.vue219
-rw-r--r--src/client/components/signup-dialog.vue22
-rw-r--r--src/client/components/signup.vue191
-rw-r--r--src/client/components/sub-note-content.vue65
-rw-r--r--src/client/components/time.vue74
-rw-r--r--src/client/components/timeline.vue118
-rw-r--r--src/client/components/toast.vue76
-rw-r--r--src/client/components/ui/button.vue204
-rw-r--r--src/client/components/ui/container.vue104
-rw-r--r--src/client/components/ui/hr.vue15
-rw-r--r--src/client/components/ui/info.vue55
-rw-r--r--src/client/components/ui/input.vue443
-rw-r--r--src/client/components/ui/pagination.vue59
-rw-r--r--src/client/components/ui/radio.vue119
-rw-r--r--src/client/components/ui/select.vue220
-rw-r--r--src/client/components/ui/switch.vue150
-rw-r--r--src/client/components/ui/textarea.vue218
-rw-r--r--src/client/components/uploader.vue242
-rw-r--r--src/client/components/url-preview.vue331
-rw-r--r--src/client/components/url.vue95
-rw-r--r--src/client/components/user-list.vue148
-rw-r--r--src/client/components/user-menu.vue188
-rw-r--r--src/client/components/user-moderate-dialog.vue108
-rw-r--r--src/client/components/user-name.vue20
-rw-r--r--src/client/components/user-preview.vue181
-rw-r--r--src/client/components/user-select.vue152
-rw-r--r--src/client/components/users-dialog.vue161
-rw-r--r--src/client/components/visibility-chooser.vue127
-rw-r--r--src/client/components/window.vue155
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>