summaryrefslogtreecommitdiff
path: root/packages/client/src/components
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/client/src/components
parentupdate deps (diff)
downloadsharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip
refactoring
Resolve #7779
Diffstat (limited to 'packages/client/src/components')
-rw-r--r--packages/client/src/components/abuse-report-window.vue79
-rw-r--r--packages/client/src/components/analog-clock.vue150
-rw-r--r--packages/client/src/components/autocomplete.vue501
-rw-r--r--packages/client/src/components/avatars.vue30
-rw-r--r--packages/client/src/components/captcha.vue123
-rw-r--r--packages/client/src/components/channel-follow-button.vue140
-rw-r--r--packages/client/src/components/channel-preview.vue165
-rw-r--r--packages/client/src/components/chart.vue691
-rw-r--r--packages/client/src/components/code-core.vue35
-rw-r--r--packages/client/src/components/code.vue27
-rw-r--r--packages/client/src/components/cw-button.vue70
-rw-r--r--packages/client/src/components/date-separated-list.vue188
-rw-r--r--packages/client/src/components/debobigego/base.vue65
-rw-r--r--packages/client/src/components/debobigego/button.vue81
-rw-r--r--packages/client/src/components/debobigego/debobigego.scss52
-rw-r--r--packages/client/src/components/debobigego/group.vue78
-rw-r--r--packages/client/src/components/debobigego/info.vue47
-rw-r--r--packages/client/src/components/debobigego/input.vue292
-rw-r--r--packages/client/src/components/debobigego/key-value-view.vue38
-rw-r--r--packages/client/src/components/debobigego/link.vue103
-rw-r--r--packages/client/src/components/debobigego/object-view.vue102
-rw-r--r--packages/client/src/components/debobigego/pagination.vue42
-rw-r--r--packages/client/src/components/debobigego/radios.vue112
-rw-r--r--packages/client/src/components/debobigego/range.vue122
-rw-r--r--packages/client/src/components/debobigego/select.vue145
-rw-r--r--packages/client/src/components/debobigego/suspense.vue101
-rw-r--r--packages/client/src/components/debobigego/switch.vue132
-rw-r--r--packages/client/src/components/debobigego/textarea.vue161
-rw-r--r--packages/client/src/components/debobigego/tuple.vue36
-rw-r--r--packages/client/src/components/dialog.vue212
-rw-r--r--packages/client/src/components/drive-file-thumbnail.vue108
-rw-r--r--packages/client/src/components/drive-select-dialog.vue70
-rw-r--r--packages/client/src/components/drive-window.vue44
-rw-r--r--packages/client/src/components/drive.file.vue374
-rw-r--r--packages/client/src/components/drive.folder.vue326
-rw-r--r--packages/client/src/components/drive.nav-folder.vue135
-rw-r--r--packages/client/src/components/drive.vue784
-rw-r--r--packages/client/src/components/emoji-picker-dialog.vue76
-rw-r--r--packages/client/src/components/emoji-picker-window.vue197
-rw-r--r--packages/client/src/components/emoji-picker.section.vue50
-rw-r--r--packages/client/src/components/emoji-picker.vue501
-rw-r--r--packages/client/src/components/featured-photos.vue32
-rw-r--r--packages/client/src/components/file-type-icon.vue28
-rw-r--r--packages/client/src/components/follow-button.vue210
-rw-r--r--packages/client/src/components/forgot-password.vue84
-rw-r--r--packages/client/src/components/form-dialog.vue125
-rw-r--r--packages/client/src/components/form/input.vue315
-rw-r--r--packages/client/src/components/form/radio.vue122
-rw-r--r--packages/client/src/components/form/radios.vue54
-rw-r--r--packages/client/src/components/form/range.vue139
-rw-r--r--packages/client/src/components/form/section.vue31
-rw-r--r--packages/client/src/components/form/select.vue312
-rw-r--r--packages/client/src/components/form/slot.vue50
-rw-r--r--packages/client/src/components/form/switch.vue150
-rw-r--r--packages/client/src/components/form/textarea.vue252
-rw-r--r--packages/client/src/components/formula-core.vue34
-rw-r--r--packages/client/src/components/formula.vue23
-rw-r--r--packages/client/src/components/gallery-post-preview.vue126
-rw-r--r--packages/client/src/components/global/a.vue138
-rw-r--r--packages/client/src/components/global/acct.vue38
-rw-r--r--packages/client/src/components/global/ad.vue200
-rw-r--r--packages/client/src/components/global/avatar.vue163
-rw-r--r--packages/client/src/components/global/ellipsis.vue34
-rw-r--r--packages/client/src/components/global/emoji.vue125
-rw-r--r--packages/client/src/components/global/error.vue46
-rw-r--r--packages/client/src/components/global/header.vue360
-rw-r--r--packages/client/src/components/global/i18n.ts42
-rw-r--r--packages/client/src/components/global/loading.vue92
-rw-r--r--packages/client/src/components/global/misskey-flavored-markdown.vue157
-rw-r--r--packages/client/src/components/global/spacer.vue76
-rw-r--r--packages/client/src/components/global/sticky-container.vue74
-rw-r--r--packages/client/src/components/global/time.vue73
-rw-r--r--packages/client/src/components/global/url.vue142
-rw-r--r--packages/client/src/components/global/user-name.vue20
-rw-r--r--packages/client/src/components/google.vue64
-rw-r--r--packages/client/src/components/image-viewer.vue85
-rw-r--r--packages/client/src/components/img-with-blurhash.vue100
-rw-r--r--packages/client/src/components/index.ts37
-rw-r--r--packages/client/src/components/instance-stats.vue80
-rw-r--r--packages/client/src/components/instance-ticker.vue62
-rw-r--r--packages/client/src/components/launch-pad.vue152
-rw-r--r--packages/client/src/components/link.vue92
-rw-r--r--packages/client/src/components/media-banner.vue107
-rw-r--r--packages/client/src/components/media-caption.vue259
-rw-r--r--packages/client/src/components/media-image.vue155
-rw-r--r--packages/client/src/components/media-list.vue167
-rw-r--r--packages/client/src/components/media-video.vue97
-rw-r--r--packages/client/src/components/mention.vue84
-rw-r--r--packages/client/src/components/mfm.ts321
-rw-r--r--packages/client/src/components/mini-chart.vue90
-rw-r--r--packages/client/src/components/modal-page-window.vue223
-rw-r--r--packages/client/src/components/note-detailed.vue1229
-rw-r--r--packages/client/src/components/note-header.vue115
-rw-r--r--packages/client/src/components/note-preview.vue98
-rw-r--r--packages/client/src/components/note-simple.vue113
-rw-r--r--packages/client/src/components/note.sub.vue146
-rw-r--r--packages/client/src/components/note.vue1228
-rw-r--r--packages/client/src/components/notes.vue130
-rw-r--r--packages/client/src/components/notification-setting-window.vue99
-rw-r--r--packages/client/src/components/notification.vue362
-rw-r--r--packages/client/src/components/notifications.vue159
-rw-r--r--packages/client/src/components/number-diff.vue47
-rw-r--r--packages/client/src/components/page-preview.vue162
-rw-r--r--packages/client/src/components/page-window.vue167
-rw-r--r--packages/client/src/components/page/page.block.vue44
-rw-r--r--packages/client/src/components/page/page.button.vue66
-rw-r--r--packages/client/src/components/page/page.canvas.vue49
-rw-r--r--packages/client/src/components/page/page.counter.vue52
-rw-r--r--packages/client/src/components/page/page.if.vue31
-rw-r--r--packages/client/src/components/page/page.image.vue40
-rw-r--r--packages/client/src/components/page/page.note.vue47
-rw-r--r--packages/client/src/components/page/page.number-input.vue55
-rw-r--r--packages/client/src/components/page/page.post.vue109
-rw-r--r--packages/client/src/components/page/page.radio-button.vue45
-rw-r--r--packages/client/src/components/page/page.section.vue60
-rw-r--r--packages/client/src/components/page/page.switch.vue55
-rw-r--r--packages/client/src/components/page/page.text-input.vue55
-rw-r--r--packages/client/src/components/page/page.text.vue68
-rw-r--r--packages/client/src/components/page/page.textarea-input.vue47
-rw-r--r--packages/client/src/components/page/page.textarea.vue39
-rw-r--r--packages/client/src/components/page/page.vue86
-rw-r--r--packages/client/src/components/particle.vue114
-rw-r--r--packages/client/src/components/poll-editor.vue251
-rw-r--r--packages/client/src/components/poll.vue174
-rw-r--r--packages/client/src/components/post-form-attaches.vue193
-rw-r--r--packages/client/src/components/post-form-dialog.vue19
-rw-r--r--packages/client/src/components/post-form.vue980
-rw-r--r--packages/client/src/components/queue-chart.vue232
-rw-r--r--packages/client/src/components/reaction-icon.vue25
-rw-r--r--packages/client/src/components/reaction-tooltip.vue51
-rw-r--r--packages/client/src/components/reactions-viewer.details.vue91
-rw-r--r--packages/client/src/components/reactions-viewer.reaction.vue183
-rw-r--r--packages/client/src/components/reactions-viewer.vue48
-rw-r--r--packages/client/src/components/remote-caution.vue35
-rw-r--r--packages/client/src/components/sample.vue116
-rw-r--r--packages/client/src/components/signin-dialog.vue42
-rw-r--r--packages/client/src/components/signin.vue240
-rw-r--r--packages/client/src/components/signup-dialog.vue50
-rw-r--r--packages/client/src/components/signup.vue268
-rw-r--r--packages/client/src/components/sparkle.vue179
-rw-r--r--packages/client/src/components/sub-note-content.vue62
-rw-r--r--packages/client/src/components/tab.vue73
-rw-r--r--packages/client/src/components/taskmanager.api-window.vue72
-rw-r--r--packages/client/src/components/taskmanager.vue233
-rw-r--r--packages/client/src/components/timeline.vue183
-rw-r--r--packages/client/src/components/toast.vue73
-rw-r--r--packages/client/src/components/token-generate-window.vue117
-rw-r--r--packages/client/src/components/ui/button.vue262
-rw-r--r--packages/client/src/components/ui/container.vue262
-rw-r--r--packages/client/src/components/ui/context-menu.vue97
-rw-r--r--packages/client/src/components/ui/folder.vue156
-rw-r--r--packages/client/src/components/ui/hr.vue16
-rw-r--r--packages/client/src/components/ui/info.vue45
-rw-r--r--packages/client/src/components/ui/menu.vue278
-rw-r--r--packages/client/src/components/ui/modal-window.vue148
-rw-r--r--packages/client/src/components/ui/modal.vue292
-rw-r--r--packages/client/src/components/ui/pagination.vue69
-rw-r--r--packages/client/src/components/ui/popup-menu.vue42
-rw-r--r--packages/client/src/components/ui/popup.vue213
-rw-r--r--packages/client/src/components/ui/super-menu.vue148
-rw-r--r--packages/client/src/components/ui/tooltip.vue92
-rw-r--r--packages/client/src/components/ui/window.vue525
-rw-r--r--packages/client/src/components/updated.vue62
-rw-r--r--packages/client/src/components/url-preview-popup.vue60
-rw-r--r--packages/client/src/components/url-preview.vue334
-rw-r--r--packages/client/src/components/user-info.vue142
-rw-r--r--packages/client/src/components/user-list.vue91
-rw-r--r--packages/client/src/components/user-online-indicator.vue50
-rw-r--r--packages/client/src/components/user-preview.vue192
-rw-r--r--packages/client/src/components/user-select-dialog.vue199
-rw-r--r--packages/client/src/components/users-dialog.vue147
-rw-r--r--packages/client/src/components/visibility-picker.vue167
-rw-r--r--packages/client/src/components/waiting-dialog.vue92
-rw-r--r--packages/client/src/components/widgets.vue152
174 files changed, 26267 insertions, 0 deletions
diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue
new file mode 100644
index 0000000000..700ce30bb2
--- /dev/null
+++ b/packages/client/src/components/abuse-report-window.vue
@@ -0,0 +1,79 @@
+<template>
+<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')">
+ <template #header>
+ <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i>
+ <I18n :src="$ts.reportAbuseOf" tag="span">
+ <template #name>
+ <b><MkAcct :user="user"/></b>
+ </template>
+ </I18n>
+ </template>
+ <div class="dpvffvvy _monolithic_">
+ <div class="_section">
+ <MkTextarea v-model="comment">
+ <template #label>{{ $ts.details }}</template>
+ <template #caption>{{ $ts.fillAbuseReportDescription }}</template>
+ </MkTextarea>
+ </div>
+ <div class="_section">
+ <MkButton @click="send" primary full :disabled="comment.length === 0">{{ $ts.send }}</MkButton>
+ </div>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XWindow from '@/components/ui/window.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ MkTextarea,
+ MkButton,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ initialComment: {
+ type: String,
+ required: false,
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ comment: this.initialComment || '',
+ };
+ },
+
+ methods: {
+ send() {
+ os.apiWithDialog('users/report-abuse', {
+ userId: this.user.id,
+ comment: this.comment,
+ }, undefined, res => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.abuseReported
+ });
+ this.$refs.window.close();
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.dpvffvvy {
+ --root-margin: 16px;
+}
+</style>
diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue
new file mode 100644
index 0000000000..bc572e5fff
--- /dev/null
+++ b/packages/client/src/components/analog-clock.vue
@@ -0,0 +1,150 @@
+<template>
+<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
+ <circle v-for="(angle, i) in graduations"
+ :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
+ :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
+ :r="i % 5 == 0 ? 0.125 : 0.05"
+ :fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor"
+ :key="i"
+ />
+
+ <line
+ :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
+ :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
+ :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
+ :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
+ :stroke="sHandColor"
+ :stroke-width="thickness / 2"
+ stroke-linecap="round"
+ />
+
+ <line
+ :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))"
+ :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))"
+ :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
+ :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
+ :stroke="mHandColor"
+ :stroke-width="thickness"
+ stroke-linecap="round"
+ />
+
+ <line
+ :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))"
+ :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))"
+ :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
+ :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
+ :stroke="hHandColor"
+ :stroke-width="thickness"
+ stroke-linecap="round"
+ />
+</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+
+export default defineComponent({
+ props: {
+ thickness: {
+ type: Number,
+ default: 0.1
+ }
+ },
+
+ data() {
+ return {
+ now: new Date(),
+ enabled: true,
+
+ graduationsPadding: 0.5,
+ handsPadding: 1,
+ handsTailLength: 0.7,
+ hHandLengthRatio: 0.75,
+ mHandLengthRatio: 1,
+ sHandLengthRatio: 1,
+
+ computedStyle: getComputedStyle(document.documentElement)
+ };
+ },
+
+ computed: {
+ dark(): boolean {
+ return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark();
+ },
+
+ majorGraduationColor(): string {
+ return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
+ },
+ minorGraduationColor(): string {
+ return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+ },
+
+ sHandColor(): string {
+ return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
+ },
+ mHandColor(): string {
+ return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString();
+ },
+ hHandColor(): string {
+ return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString();
+ },
+
+ s(): number {
+ return this.now.getSeconds();
+ },
+ m(): number {
+ return this.now.getMinutes();
+ },
+ h(): number {
+ return this.now.getHours();
+ },
+
+ hAngle(): number {
+ return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6;
+ },
+ mAngle(): number {
+ return Math.PI * (this.m + this.s / 60) / 30;
+ },
+ sAngle(): number {
+ return Math.PI * this.s / 30;
+ },
+
+ graduations(): any {
+ const angles = [];
+ for (let i = 0; i < 60; i++) {
+ const angle = Math.PI * i / 30;
+ angles.push(angle);
+ }
+
+ return angles;
+ }
+ },
+
+ mounted() {
+ const update = () => {
+ if (this.enabled) {
+ this.tick();
+ setTimeout(update, 1000);
+ }
+ };
+ update();
+ },
+
+ beforeUnmount() {
+ this.enabled = false;
+ },
+
+ methods: {
+ tick() {
+ this.now = new Date();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mbcofsoe {
+ display: block;
+}
+</style>
diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue
new file mode 100644
index 0000000000..a7d2d507e0
--- /dev/null
+++ b/packages/client/src/components/autocomplete.vue
@@ -0,0 +1,501 @@
+<template>
+<div class="swhvrteh _popup _shadow" @contextmenu.prevent="() => {}">
+ <ol class="users" ref="suggests" v-if="type === 'user'">
+ <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user">
+ <img class="avatar" :src="user.avatarUrl"/>
+ <span class="name">
+ <MkUserName :user="user" :key="user.id"/>
+ </span>
+ <span class="username">@{{ acct(user) }}</span>
+ </li>
+ <li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $ts.selectUser }}</li>
+ </ol>
+ <ol class="hashtags" ref="suggests" v-else-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-else-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.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
+ <span class="emoji" v-else-if="!$store.state.useOsNativeEmojis"><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>
+ <ol class="mfmTags" ref="suggests" v-else-if="mfmTags.length > 0">
+ <li v-for="tag in mfmTags" @click="complete(type, tag)" @keydown="onKeydown" tabindex="-1">
+ <span class="tag">{{ tag }}</span>
+ </li>
+ </ol>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import { emojilist } from '@/scripts/emojilist';
+import contains from '@/scripts/contains';
+import { twemojiSvgBase } from '@/scripts/twemoji-base';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import { instance } from '@/instance';
+import { MFM_TAGS } from '@/scripts/mfm-tags';
+
+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);
+
+//#region Construct Emoji DB
+const customEmojis = instance.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);
+
+const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
+//#endregion
+
+export default defineComponent({
+ props: {
+ type: {
+ type: String,
+ required: true,
+ },
+
+ q: {
+ type: String,
+ required: false,
+ },
+
+ textarea: {
+ type: HTMLTextAreaElement,
+ required: true,
+ },
+
+ close: {
+ type: Function,
+ required: true,
+ },
+
+ x: {
+ type: Number,
+ required: true,
+ },
+
+ y: {
+ type: Number,
+ required: true,
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ getStaticImageUrl,
+ fetching: true,
+ users: [],
+ hashtags: [],
+ emojis: [],
+ items: [],
+ mfmTags: [],
+ select: -1,
+ }
+ },
+
+ updated() {
+ this.setPosition();
+ this.items = (this.$refs.suggests as Element | undefined)?.children || [];
+ },
+
+ mounted() {
+ this.setPosition();
+
+ this.textarea.addEventListener('keydown', this.onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.addEventListener('mousedown', this.onMousedown);
+ }
+
+ this.$nextTick(() => {
+ this.exec();
+
+ this.$watch('q', () => {
+ this.$nextTick(() => {
+ this.exec();
+ });
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.textarea.removeEventListener('keydown', this.onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.removeEventListener('mousedown', this.onMousedown);
+ }
+ },
+
+ methods: {
+ complete(type, value) {
+ this.$emit('done', { type, value });
+ this.$emit('closed');
+
+ if (type === 'emoji') {
+ let recents = this.$store.state.recentlyUsedEmojis;
+ recents = recents.filter((e: any) => e !== value);
+ recents.unshift(value);
+ this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
+ }
+ },
+
+ setPosition() {
+ 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)';
+ }
+ },
+
+ exec() {
+ this.select = -1;
+ if (this.$refs.suggests) {
+ for (const el of Array.from(this.items)) {
+ el.removeAttribute('data-selected');
+ }
+ }
+
+ if (this.type === 'user') {
+ if (this.q == null) {
+ this.users = [];
+ this.fetching = false;
+ return;
+ }
+
+ 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 {
+ os.api('users/search-by-username-and-host', {
+ username: 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 {
+ os.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.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null);
+ return;
+ }
+
+ const matched = [];
+ const max = 30;
+
+ 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) {
+ 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) {
+ 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;
+ } else if (this.type === 'mfmTag') {
+ if (this.q == null || this.q == '') {
+ this.mfmTags = MFM_TAGS;
+ return;
+ }
+
+ this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q));
+ }
+ },
+
+ 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;
+ if (this.items.length === 0) this.select = -1;
+ 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');
+ }
+
+ if (this.select !== -1) {
+ this.items[this.select].setAttribute('data-selected', 'true');
+ (this.items[this.select] as any).focus();
+ }
+ },
+
+ chooseUser() {
+ this.close();
+ os.selectUser().then(user => {
+ this.complete('user', user);
+ this.textarea.focus();
+ });
+ },
+
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.swhvrteh {
+ position: fixed;
+ z-index: 65535;
+ max-width: 100%;
+ margin-top: calc(1em + 8px);
+ overflow: hidden;
+ 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(--X3);
+ }
+
+ &[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;
+ }
+ }
+
+ > .emojis > li {
+
+ .emoji {
+ display: inline-block;
+ margin: 0 4px 0 0;
+ width: 24px;
+
+ > img {
+ width: 24px;
+ vertical-align: bottom;
+ }
+ }
+
+ .alias {
+ margin: 0 0 0 8px;
+ }
+ }
+
+ > .mfmTags > li {
+
+ .name {
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/avatars.vue b/packages/client/src/components/avatars.vue
new file mode 100644
index 0000000000..e843d26daa
--- /dev/null
+++ b/packages/client/src/components/avatars.vue
@@ -0,0 +1,30 @@
+<template>
+<div>
+ <div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
+ <MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ userIds: {
+ required: true
+ },
+ },
+ data() {
+ return {
+ us: []
+ };
+ },
+ async created() {
+ this.us = await os.api('users/show', {
+ userIds: this.userIds
+ });
+ }
+});
+</script>
diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue
new file mode 100644
index 0000000000..baa922506e
--- /dev/null
+++ b/packages/client/src/components/captcha.vue
@@ -0,0 +1,123 @@
+<template>
+<div>
+ <span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span>
+ <div ref="captcha"></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+type Captcha = {
+ render(container: string | Node, options: {
+ readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
+ }): string;
+ remove(id: string): void;
+ execute(id: string): void;
+ reset(id: string): void;
+ getResponse(id: string): string;
+};
+
+type CaptchaProvider = 'hcaptcha' | 'recaptcha';
+
+type CaptchaContainer = {
+ readonly [_ in CaptchaProvider]?: Captcha;
+};
+
+declare global {
+ interface Window extends CaptchaContainer {
+ }
+}
+
+export default defineComponent({
+ props: {
+ provider: {
+ type: String as PropType<CaptchaProvider>,
+ required: true,
+ },
+ sitekey: {
+ type: String,
+ required: true,
+ },
+ modelValue: {
+ type: String,
+ },
+ },
+
+ data() {
+ return {
+ available: false,
+ };
+ },
+
+ computed: {
+ variable(): string {
+ switch (this.provider) {
+ case 'hcaptcha': return 'hcaptcha';
+ case 'recaptcha': return 'grecaptcha';
+ }
+ },
+ loaded(): boolean {
+ return !!window[this.variable];
+ },
+ src(): string {
+ const endpoint = ({
+ hcaptcha: 'https://hcaptcha.com/1',
+ recaptcha: 'https://www.recaptcha.net/recaptcha',
+ } as Record<CaptchaProvider, string>)[this.provider];
+
+ return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
+ },
+ captcha(): Captcha {
+ return window[this.variable] || {} as unknown as Captcha;
+ },
+ },
+
+ created() {
+ if (this.loaded) {
+ this.available = true;
+ } else {
+ (document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
+ async: true,
+ id: this.provider,
+ src: this.src,
+ })))
+ .addEventListener('load', () => this.available = true);
+ }
+ },
+
+ mounted() {
+ if (this.available) {
+ this.requestRender();
+ } else {
+ this.$watch('available', this.requestRender);
+ }
+ },
+
+ beforeUnmount() {
+ this.reset();
+ },
+
+ methods: {
+ reset() {
+ if (this.captcha?.reset) this.captcha.reset();
+ },
+ requestRender() {
+ if (this.captcha.render && this.$refs.captcha instanceof Element) {
+ this.captcha.render(this.$refs.captcha, {
+ sitekey: this.sitekey,
+ theme: this.$store.state.darkMode ? 'dark' : 'light',
+ callback: this.callback,
+ 'expired-callback': this.callback,
+ 'error-callback': this.callback,
+ });
+ } else {
+ setTimeout(this.requestRender.bind(this), 1);
+ }
+ },
+ callback(response?: string) {
+ this.$emit('update:modelValue', typeof response == 'string' ? response : null);
+ },
+ },
+});
+</script>
diff --git a/packages/client/src/components/channel-follow-button.vue b/packages/client/src/components/channel-follow-button.vue
new file mode 100644
index 0000000000..9af65325bb
--- /dev/null
+++ b/packages/client/src/components/channel-follow-button.vue
@@ -0,0 +1,140 @@
+<template>
+<button class="hdcaacmi _button"
+ :class="{ wait, active: isFollowing, full }"
+ @click="onClick"
+ :disabled="wait"
+>
+ <template v-if="!wait">
+ <template v-if="isFollowing">
+ <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
+ </template>
+ <template v-else>
+ <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
+ </template>
+ </template>
+ <template v-else>
+ <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
+ </template>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ channel: {
+ type: Object,
+ required: true
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ isFollowing: this.channel.isFollowing,
+ wait: false,
+ };
+ },
+
+ methods: {
+ async onClick() {
+ this.wait = true;
+
+ try {
+ if (this.isFollowing) {
+ await os.api('channels/unfollow', {
+ channelId: this.channel.id
+ });
+ this.isFollowing = false;
+ } else {
+ await os.api('channels/follow', {
+ channelId: this.channel.id
+ });
+ this.isFollowing = true;
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ this.wait = false;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hdcaacmi {
+ position: relative;
+ display: inline-block;
+ font-weight: bold;
+ color: var(--accent);
+ background: transparent;
+ border: solid 1px var(--accent);
+ padding: 0;
+ height: 31px;
+ font-size: 16px;
+ border-radius: 32px;
+ background: #fff;
+
+ &.full {
+ padding: 0 8px 0 12px;
+ font-size: 14px;
+ }
+
+ &:not(.full) {
+ width: 31px;
+ }
+
+ &:focus-visible {
+ &:after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ bottom: -5px;
+ left: -5px;
+ border: 2px solid var(--focus);
+ border-radius: 32px;
+ }
+ }
+
+ &: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;
+ }
+
+ > span {
+ margin-right: 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/channel-preview.vue b/packages/client/src/components/channel-preview.vue
new file mode 100644
index 0000000000..eb00052a78
--- /dev/null
+++ b/packages/client/src/components/channel-preview.vue
@@ -0,0 +1,165 @@
+<template>
+<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
+ <div class="banner" :style="bannerStyle">
+ <div class="fade"></div>
+ <div class="name"><i class="fas fa-satellite-dish"></i> {{ channel.name }}</div>
+ <div class="status">
+ <div>
+ <i class="fas fa-users fa-fw"></i>
+ <I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;">
+ <template #n>
+ <b>{{ channel.usersCount }}</b>
+ </template>
+ </I18n>
+ </div>
+ <div>
+ <i class="fas fa-pencil-alt fa-fw"></i>
+ <I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;">
+ <template #n>
+ <b>{{ channel.notesCount }}</b>
+ </template>
+ </I18n>
+ </div>
+ </div>
+ </div>
+ <article v-if="channel.description">
+ <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
+ </article>
+ <footer>
+ <span v-if="channel.lastNotedAt">
+ {{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
+ </span>
+ </footer>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ channel: {
+ type: Object,
+ required: true
+ },
+ },
+
+ computed: {
+ bannerStyle() {
+ if (this.channel.bannerUrl) {
+ return { backgroundImage: `url(${this.channel.bannerUrl})` };
+ } else {
+ return { backgroundColor: '#4c5e6d' };
+ }
+ }
+ },
+
+ data() {
+ return {
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.eftoefju {
+ display: block;
+ overflow: hidden;
+ width: 100%;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ > .banner {
+ position: relative;
+ width: 100%;
+ height: 200px;
+ background-position: center;
+ background-size: cover;
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+
+ > .name {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ padding: 12px 16px;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ font-size: 1.2em;
+ }
+
+ > .status {
+ position: absolute;
+ z-index: 1;
+ bottom: 16px;
+ right: 16px;
+ padding: 8px 12px;
+ font-size: 80%;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 6px;
+ color: #fff;
+ }
+ }
+
+ > article {
+ padding: 16px;
+
+ > p {
+ margin: 0;
+ font-size: 1em;
+ }
+ }
+
+ > footer {
+ padding: 12px 16px;
+ border-top: solid 0.5px var(--divider);
+
+ > span {
+ opacity: 0.7;
+ font-size: 0.9em;
+ }
+ }
+
+ @media (max-width: 550px) {
+ font-size: 0.9em;
+
+ > .banner {
+ height: 80px;
+
+ > .status {
+ display: none;
+ }
+ }
+
+ > article {
+ padding: 12px;
+ }
+
+ > footer {
+ display: none;
+ }
+ }
+
+ @media (max-width: 500px) {
+ font-size: 0.8em;
+
+ > .banner {
+ height: 70px;
+ }
+
+ > article {
+ padding: 8px;
+ }
+ }
+}
+
+</style>
diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue
new file mode 100644
index 0000000000..c4d0eb85dd
--- /dev/null
+++ b/packages/client/src/components/chart.vue
@@ -0,0 +1,691 @@
+<template>
+<div class="cbbedffa">
+ <canvas ref="chartEl"></canvas>
+ <div v-if="fetching" class="fetching">
+ <MkLoading/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref, watch, PropType } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import 'chartjs-adapter-date-fns';
+import { enUS } from 'date-fns/locale';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ zoomPlugin,
+);
+
+const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
+const negate = arr => arr.map(x => -x);
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560'];
+const getColor = (i) => {
+ return colors[i % colors.length];
+};
+
+export default defineComponent({
+ props: {
+ src: {
+ type: String,
+ required: true,
+ },
+ args: {
+ type: Object,
+ required: false,
+ },
+ limit: {
+ type: Number,
+ required: false,
+ default: 90
+ },
+ span: {
+ type: String as PropType<'hour' | 'day'>,
+ required: true,
+ },
+ detailed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ stacked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ aspectRatio: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ },
+
+ setup(props) {
+ const now = new Date();
+ let chartInstance: Chart = null;
+ let data: {
+ series: {
+ name: string;
+ type: 'line' | 'area';
+ color?: string;
+ borderDash?: number[];
+ hidden?: boolean;
+ data: {
+ x: number;
+ y: number;
+ }[];
+ }[];
+ } = null;
+
+ const chartEl = ref<HTMLCanvasElement>(null);
+ const fetching = ref(true);
+
+ const getDate = (ago: number) => {
+ const y = now.getFullYear();
+ const m = now.getMonth();
+ const d = now.getDate();
+ const h = now.getHours();
+
+ return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+ };
+
+ const format = (arr) => {
+ return arr.map((v, i) => ({
+ x: getDate(i).getTime(),
+ y: v
+ }));
+ };
+
+ const render = () => {
+ if (chartInstance) {
+ chartInstance.destroy();
+ }
+
+ const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
+ datasets: data.series.map((x, i) => ({
+ parsing: false,
+ label: x.name,
+ data: x.data.slice().reverse(),
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: x.color ? x.color : getColor(i),
+ borderDash: x.borderDash || [],
+ borderJoinStyle: 'round',
+ backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1),
+ fill: x.type === 'area',
+ hidden: !!x.hidden,
+ })),
+ },
+ options: {
+ aspectRatio: props.aspectRatio || 2.5,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 8,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ time: {
+ stepSize: 1,
+ unit: props.span === 'day' ? 'month' : 'day',
+ },
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: props.detailed,
+ },
+ adapters: {
+ date: {
+ locale: enUS,
+ },
+ },
+ min: getDate(props.limit).getTime(),
+ },
+ y: {
+ position: 'left',
+ stacked: props.stacked,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: props.detailed,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: props.detailed,
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ },
+ },
+ tooltip: {
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ },
+ zoom: {
+ pan: {
+ enabled: true,
+ },
+ zoom: {
+ wheel: {
+ enabled: true,
+ },
+ pinch: {
+ enabled: true,
+ },
+ drag: {
+ enabled: false,
+ },
+ mode: 'x',
+ },
+ limits: {
+ x: {
+ min: 'original',
+ max: 'original',
+ },
+ y: {
+ min: 'original',
+ max: 'original',
+ },
+ }
+ },
+ },
+ },
+ });
+ };
+
+ const exportData = () => {
+ // TODO
+ };
+
+ const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/federation', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Instances',
+ type: 'area',
+ data: format(total
+ ? raw.instance.total
+ : sum(raw.instance.inc, negate(raw.instance.dec))
+ ),
+ }],
+ };
+ };
+
+ const fetchNotesChart = async (type: string): Promise<typeof data> => {
+ const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(type == 'combined'
+ ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+ : sum(raw[type].inc, negate(raw[type].dec))
+ ),
+ }, {
+ name: 'Renotes',
+ type: 'area',
+ data: format(type == 'combined'
+ ? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
+ : raw[type].diffs.renote
+ ),
+ }, {
+ name: 'Replies',
+ type: 'area',
+ data: format(type == 'combined'
+ ? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
+ : raw[type].diffs.reply
+ ),
+ }, {
+ name: 'Normal',
+ type: 'area',
+ data: format(type == 'combined'
+ ? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
+ : raw[type].diffs.normal
+ ),
+ }],
+ };
+ };
+
+ const fetchNotesTotalChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.total, raw.remote.total)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.total),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.total),
+ }],
+ };
+ };
+
+ const fetchUsersChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/users', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(total
+ ? sum(raw.local.total, raw.remote.total)
+ : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+ ),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(total
+ ? raw.local.total
+ : sum(raw.local.inc, negate(raw.local.dec))
+ ),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(total
+ ? raw.remote.total
+ : sum(raw.remote.inc, negate(raw.remote.dec))
+ ),
+ }],
+ };
+ };
+
+ const fetchActiveUsersChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.users, raw.remote.users)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.users),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.users),
+ }],
+ };
+ };
+
+ const fetchDriveChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ bytes: true,
+ series: [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(
+ sum(
+ raw.local.incSize,
+ negate(raw.local.decSize),
+ raw.remote.incSize,
+ negate(raw.remote.decSize)
+ )
+ ),
+ }, {
+ name: 'Local +',
+ type: 'area',
+ data: format(raw.local.incSize),
+ }, {
+ name: 'Local -',
+ type: 'area',
+ data: format(negate(raw.local.decSize)),
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ data: format(raw.remote.incSize),
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ data: format(negate(raw.remote.decSize)),
+ }],
+ };
+ };
+
+ const fetchDriveTotalChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ bytes: true,
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.totalSize, raw.remote.totalSize)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.totalSize),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.totalSize),
+ }],
+ };
+ };
+
+ const fetchDriveFilesChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(
+ sum(
+ raw.local.incCount,
+ negate(raw.local.decCount),
+ raw.remote.incCount,
+ negate(raw.remote.decCount)
+ )
+ ),
+ }, {
+ name: 'Local +',
+ type: 'area',
+ data: format(raw.local.incCount),
+ }, {
+ name: 'Local -',
+ type: 'area',
+ data: format(negate(raw.local.decCount)),
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ data: format(raw.remote.incCount),
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ data: format(negate(raw.remote.decCount)),
+ }],
+ };
+ };
+
+ const fetchDriveFilesTotalChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.totalCount, raw.remote.totalCount)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.totalCount),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.totalCount),
+ }],
+ };
+ };
+
+ const fetchInstanceRequestsChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'In',
+ type: 'area',
+ color: '#008FFB',
+ data: format(raw.requests.received)
+ }, {
+ name: 'Out (succ)',
+ type: 'area',
+ color: '#00E396',
+ data: format(raw.requests.succeeded)
+ }, {
+ name: 'Out (fail)',
+ type: 'area',
+ color: '#FEB019',
+ data: format(raw.requests.failed)
+ }]
+ };
+ };
+
+ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Users',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.users.total
+ : sum(raw.users.inc, negate(raw.users.dec))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Notes',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.notes.total
+ : sum(raw.notes.inc, negate(raw.notes.dec))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Following',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.following.total
+ : sum(raw.following.inc, negate(raw.following.dec))
+ )
+ }, {
+ name: 'Followers',
+ type: 'area',
+ color: '#00E396',
+ data: format(total
+ ? raw.followers.total
+ : sum(raw.followers.inc, negate(raw.followers.dec))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ bytes: true,
+ series: [{
+ name: 'Drive usage',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.drive.totalUsage
+ : sum(raw.drive.incUsage, negate(raw.drive.decUsage))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Drive files',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.drive.totalFiles
+ : sum(raw.drive.incFiles, negate(raw.drive.decFiles))
+ )
+ }]
+ };
+ };
+
+ const fetchPerUserNotesChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span });
+ return {
+ series: [...(props.args.withoutAll ? [] : [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(sum(raw.inc, negate(raw.dec))),
+ }]), {
+ name: 'Renotes',
+ type: 'area',
+ data: format(raw.diffs.renote),
+ }, {
+ name: 'Replies',
+ type: 'area',
+ data: format(raw.diffs.reply),
+ }, {
+ name: 'Normal',
+ type: 'area',
+ data: format(raw.diffs.normal),
+ }],
+ };
+ };
+
+ const fetchAndRender = async () => {
+ const fetchData = () => {
+ switch (props.src) {
+ case 'federation-instances': return fetchFederationInstancesChart(false);
+ case 'federation-instances-total': return fetchFederationInstancesChart(true);
+ case 'users': return fetchUsersChart(false);
+ case 'users-total': return fetchUsersChart(true);
+ case 'active-users': return fetchActiveUsersChart();
+ case 'notes': return fetchNotesChart('combined');
+ case 'local-notes': return fetchNotesChart('local');
+ case 'remote-notes': return fetchNotesChart('remote');
+ case 'notes-total': return fetchNotesTotalChart();
+ case 'drive': return fetchDriveChart();
+ case 'drive-total': return fetchDriveTotalChart();
+ case 'drive-files': return fetchDriveFilesChart();
+ case 'drive-files-total': return fetchDriveFilesTotalChart();
+
+ case 'instance-requests': return fetchInstanceRequestsChart();
+ case 'instance-users': return fetchInstanceUsersChart(false);
+ case 'instance-users-total': return fetchInstanceUsersChart(true);
+ case 'instance-notes': return fetchInstanceNotesChart(false);
+ case 'instance-notes-total': return fetchInstanceNotesChart(true);
+ case 'instance-ff': return fetchInstanceFfChart(false);
+ case 'instance-ff-total': return fetchInstanceFfChart(true);
+ case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false);
+ case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true);
+ case 'instance-drive-files': return fetchInstanceDriveFilesChart(false);
+ case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
+
+ case 'per-user-notes': return fetchPerUserNotesChart();
+ }
+ };
+ fetching.value = true;
+ data = await fetchData();
+ fetching.value = false;
+ render();
+ };
+
+ watch(() => [props.src, props.span], fetchAndRender);
+
+ onMounted(() => {
+ fetchAndRender();
+ });
+
+ return {
+ chartEl,
+ fetching,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.cbbedffa {
+ position: relative;
+
+ > .fetching {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(12px));
+ backdrop-filter: var(--blur, blur(12px));
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: wait;
+ }
+}
+</style>
diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue
new file mode 100644
index 0000000000..9cff7b4448
--- /dev/null
+++ b/packages/client/src/components/code-core.vue
@@ -0,0 +1,35 @@
+<template>
+<code v-if="inline" v-html="html" :class="`language-${prismLang}`"></code>
+<pre v-else :class="`language-${prismLang}`"><code v-html="html" :class="`language-${prismLang}`"></code></pre>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import 'prismjs';
+import 'prismjs/themes/prism-okaidia.css';
+
+export default defineComponent({
+ 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';
+ },
+ html() {
+ return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang);
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/code.vue b/packages/client/src/components/code.vue
new file mode 100644
index 0000000000..f5d6c5673a
--- /dev/null
+++ b/packages/client/src/components/code.vue
@@ -0,0 +1,27 @@
+<template>
+<XCode :code="code" :lang="lang" :inline="inline"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+
+export default defineComponent({
+ components: {
+ XCode: defineAsyncComponent(() => import('./code-core.vue'))
+ },
+ props: {
+ code: {
+ type: String,
+ required: true
+ },
+ lang: {
+ type: String,
+ required: false
+ },
+ inline: {
+ type: Boolean,
+ required: false
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/cw-button.vue b/packages/client/src/components/cw-button.vue
new file mode 100644
index 0000000000..4bec7b511e
--- /dev/null
+++ b/packages/client/src/components/cw-button.vue
@@ -0,0 +1,70 @@
+<template>
+<button class="nrvgflfu _button" @click="toggle">
+ <b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b>
+ <span v-if="!modelValue">{{ label }}</span>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { length } from 'stringz';
+import { concat } from '@/scripts/array';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ 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.$ts.poll] : []
+ ] as string[][]).join(' / ');
+ }
+ },
+
+ methods: {
+ length,
+
+ toggle() {
+ this.$emit('update:modelValue', !this.modelValue);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.nrvgflfu {
+ 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/packages/client/src/components/date-separated-list.vue b/packages/client/src/components/date-separated-list.vue
new file mode 100644
index 0000000000..1aea9fd353
--- /dev/null
+++ b/packages/client/src/components/date-separated-list.vue
@@ -0,0 +1,188 @@
+<script lang="ts">
+import { defineComponent, h, PropType, TransitionGroup } from 'vue';
+import MkAd from '@/components/global/ad.vue';
+
+export default defineComponent({
+ props: {
+ items: {
+ type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
+ required: true,
+ },
+ direction: {
+ type: String,
+ required: false,
+ default: 'down'
+ },
+ reversed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ methods: {
+ focus() {
+ this.$slots.default[0].elm.focus();
+ },
+
+ 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()
+ });
+ }
+ },
+
+ render() {
+ if (this.items.length === 0) return;
+
+ const renderChildren = () => this.items.map((item, i) => {
+ const el = this.$slots.default({
+ item: item
+ })[0];
+ if (el.key == null && item.id) el.key = item.id;
+
+ if (
+ i != this.items.length - 1 &&
+ new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
+ ) {
+ const separator = h('div', {
+ class: 'separator',
+ key: item.id + ':separator',
+ }, h('p', {
+ class: 'date'
+ }, [
+ h('span', [
+ h('i', {
+ class: 'fas fa-angle-up icon',
+ }),
+ this.getDateText(item.createdAt)
+ ]),
+ h('span', [
+ this.getDateText(this.items[i + 1].createdAt),
+ h('i', {
+ class: 'fas fa-angle-down icon',
+ })
+ ])
+ ]));
+
+ return [el, separator];
+ } else {
+ if (this.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ class: 'a', // advertiseの意(ブロッカー対策)
+ key: item.id + ':ad',
+ prefer: ['horizontal', 'horizontal-big'],
+ }), el];
+ } else {
+ return el;
+ }
+ }
+ });
+
+ return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? {
+ class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
+ name: 'list',
+ tag: 'div',
+ 'data-direction': this.direction,
+ 'data-reversed': this.reversed ? 'true' : 'false',
+ } : {
+ class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
+ }, {
+ default: renderChildren
+ });
+ },
+});
+</script>
+
+<style lang="scss">
+.sqadhkmv {
+ > *:empty {
+ display: none;
+ }
+
+ > *:not(:last-child) {
+ margin-bottom: var(--margin);
+ }
+
+ > .list-move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+
+ > .list-enter-active {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+
+ &[data-direction="up"] {
+ > .list-enter-from {
+ opacity: 0;
+ transform: translateY(64px);
+ }
+ }
+
+ &[data-direction="down"] {
+ > .list-enter-from {
+ opacity: 0;
+ transform: translateY(-64px);
+ }
+ }
+
+ > .separator {
+ text-align: center;
+
+ > .date {
+ display: inline-block;
+ position: relative;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--dateLabelFg);
+
+ > span {
+ &:first-child {
+ margin-right: 8px;
+
+ > .icon {
+ margin-right: 8px;
+ }
+ }
+
+ &:last-child {
+ margin-left: 8px;
+
+ > .icon {
+ margin-left: 8px;
+ }
+ }
+ }
+ }
+ }
+
+ &.noGap {
+ > * {
+ margin: 0 !important;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+
+ &:not(:last-child) {
+ border-bottom: solid 0.5px var(--divider);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/base.vue b/packages/client/src/components/debobigego/base.vue
new file mode 100644
index 0000000000..f551a3478b
--- /dev/null
+++ b/packages/client/src/components/debobigego/base.vue
@@ -0,0 +1,65 @@
+<template>
+<div class="rbusrurv" :class="{ wide: forceWide }" v-size="{ max: [400] }">
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ forceWide: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rbusrurv {
+ // 他のCSSからも参照されるので消さないように
+ --debobigegoXPadding: 32px;
+ --debobigegoYPadding: 32px;
+
+ --debobigegoContentHMargin: 16px;
+
+ font-size: 95%;
+ line-height: 1.3em;
+ background: var(--bg);
+ padding: var(--debobigegoYPadding) var(--debobigegoXPadding);
+ max-width: 750px;
+ margin: 0 auto;
+
+ &:not(.wide).max-width_400px {
+ --debobigegoXPadding: 0px;
+
+ > ::v-deep(*) {
+ ._debobigegoPanel {
+ border: solid 0.5px var(--divider);
+ border-radius: 0;
+ border-left: none;
+ border-right: none;
+ }
+
+ ._debobigego_group {
+ > *:not(._debobigegoNoConcat) {
+ &:not(:last-child):not(._debobigegoNoConcatPrev) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-bottom: solid 0.5px var(--divider);
+ }
+ }
+
+ &:not(:first-child):not(._debobigegoNoConcatNext) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-top: none;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/button.vue b/packages/client/src/components/debobigego/button.vue
new file mode 100644
index 0000000000..b883e817a4
--- /dev/null
+++ b/packages/client/src/components/debobigego/button.vue
@@ -0,0 +1,81 @@
+<template>
+<div class="yzpgjkxe _debobigegoItem">
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <button class="main _button _debobigegoPanel _debobigegoClickable" :class="{ center, primary, danger }">
+ <slot></slot>
+ <div class="suffix">
+ <slot name="suffix"></slot>
+ <div class="icon">
+ <slot name="suffixIcon"></slot>
+ </div>
+ </div>
+ </button>
+ <div class="_debobigegoCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ primary: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ danger: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ center: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.yzpgjkxe {
+ > .main {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 14px 16px;
+ text-align: left;
+ align-items: center;
+
+ &.center {
+ display: block;
+ text-align: center;
+ }
+
+ &.primary {
+ color: var(--accent);
+ }
+
+ &.danger {
+ color: #ff2a2a;
+ }
+
+ > .suffix {
+ display: inline-flex;
+ margin-left: auto;
+ opacity: 0.7;
+
+ > .icon {
+ margin-left: 1em;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/debobigego.scss b/packages/client/src/components/debobigego/debobigego.scss
new file mode 100644
index 0000000000..833b656b66
--- /dev/null
+++ b/packages/client/src/components/debobigego/debobigego.scss
@@ -0,0 +1,52 @@
+._debobigegoPanel {
+ background: var(--panel);
+ border-radius: var(--radius);
+ transition: background 0.2s ease;
+
+ &._debobigegoClickable {
+ &:hover {
+ //background: var(--panelHighlight);
+ }
+
+ &:active {
+ background: var(--panelHighlight);
+ transition: background 0s;
+ }
+ }
+}
+
+._debobigegoLabel,
+._debobigegoCaption {
+ font-size: 80%;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+}
+
+._debobigegoLabel {
+ position: sticky;
+ top: var(--stickyTop, 0px);
+ z-index: 2;
+ margin: -8px calc(var(--debobigegoXPadding) * -1) 0 calc(var(--debobigegoXPadding) * -1);
+ padding: 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)) 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding));
+ background: var(--X17);
+ -webkit-backdrop-filter: var(--blur, blur(10px));
+ backdrop-filter: var(--blur, blur(10px));
+}
+
+._themeChanging_ ._debobigegoLabel {
+ transition: none !important;
+ background: transparent;
+}
+
+._debobigegoCaption {
+ padding: 8px var(--debobigegoContentHMargin) 0 var(--debobigegoContentHMargin);
+}
+
+._debobigegoItem {
+ & + ._debobigegoItem {
+ margin-top: 24px;
+ }
+}
diff --git a/packages/client/src/components/debobigego/group.vue b/packages/client/src/components/debobigego/group.vue
new file mode 100644
index 0000000000..cba2c6ec94
--- /dev/null
+++ b/packages/client/src/components/debobigego/group.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="vrtktovg _debobigegoItem _debobigegoNoConcat" v-size="{ max: [500] }" v-sticky-container>
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <div class="main _debobigego_group" ref="child">
+ <slot></slot>
+ </div>
+ <div class="_debobigegoCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref } from 'vue';
+
+export default defineComponent({
+ setup(props, context) {
+ const child = ref<HTMLElement | null>(null);
+
+ const scanChild = () => {
+ if (child.value == null) return;
+ const els = Array.from(child.value.children);
+ for (let i = 0; i < els.length; i++) {
+ const el = els[i];
+ if (el.classList.contains('_debobigegoNoConcat')) {
+ if (els[i - 1]) els[i - 1].classList.add('_debobigegoNoConcatPrev');
+ if (els[i + 1]) els[i + 1].classList.add('_debobigegoNoConcatNext');
+ }
+ }
+ };
+
+ onMounted(() => {
+ scanChild();
+
+ const observer = new MutationObserver(records => {
+ scanChild();
+ });
+
+ observer.observe(child.value, {
+ childList: true,
+ subtree: false,
+ attributes: false,
+ characterData: false,
+ });
+ });
+
+ return {
+ child
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vrtktovg {
+ > .main {
+ > ::v-deep(*):not(._debobigegoNoConcat) {
+ &:not(._debobigegoNoConcatNext) {
+ margin: 0;
+ }
+
+ &:not(:last-child):not(._debobigegoNoConcatPrev) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-bottom: solid 0.5px var(--divider);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ }
+
+ &:not(:first-child):not(._debobigegoNoConcatNext) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-top: none;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/info.vue b/packages/client/src/components/debobigego/info.vue
new file mode 100644
index 0000000000..41afb03304
--- /dev/null
+++ b/packages/client/src/components/debobigego/info.vue
@@ -0,0 +1,47 @@
+<template>
+<div class="fzenkabp _debobigegoItem">
+ <div class="_debobigegoPanel" :class="{ warn }">
+ <i v-if="warn" class="fas fa-exclamation-triangle"></i>
+ <i v-else class="fas fa-info-circle"></i>
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ warn: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fzenkabp {
+ > div {
+ padding: 14px 16px;
+ font-size: 90%;
+ background: var(--infoBg);
+ color: var(--infoFg);
+
+ &.warn {
+ background: var(--infoWarnBg);
+ color: var(--infoWarnFg);
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/input.vue b/packages/client/src/components/debobigego/input.vue
new file mode 100644
index 0000000000..d113f04d27
--- /dev/null
+++ b/packages/client/src/components/debobigego/input.vue
@@ -0,0 +1,292 @@
+<template>
+<FormGroup class="_debobigegoItem">
+ <template #label><slot></slot></template>
+ <div class="ztzhwixg _debobigegoItem" :class="{ inline, disabled }">
+ <div class="icon" ref="icon"><slot name="icon"></slot></div>
+ <div class="input _debobigegoPanel">
+ <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+ <input ref="inputEl"
+ :type="type"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ :list="id"
+ >
+ <datalist :id="id" v-if="datalist">
+ <option v-for="data in datalist" :value="data"/>
+ </datalist>
+ <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
+ </div>
+ </div>
+ <template #caption><slot name="desc"></slot></template>
+
+ <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import './debobigego.scss';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
+
+export default defineComponent({
+ components: {
+ FormGroup,
+ FormButton,
+ },
+ props: {
+ modelValue: {
+ 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
+ },
+ step: {
+ required: false
+ },
+ datalist: {
+ type: Array,
+ required: false,
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+ setup(props, context) {
+ const { modelValue, type, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const id = Math.random().toString(); // TODO: uuid?
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+ const prefixEl = ref(null);
+ const suffixEl = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ context.emit('enter');
+ }
+ };
+
+ const updated = () => {
+ changed.value = false;
+ if (type?.value === 'number') {
+ context.emit('update:modelValue', parseFloat(v.value));
+ } else {
+ context.emit('update:modelValue', v.value);
+ }
+ };
+
+ watch(modelValue.value, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ updated();
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+
+ // このコンポーネントが作成された時、非表示状態である場合がある
+ // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+ const clock = setInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+ }, 100);
+
+ onUnmounted(() => {
+ clearInterval(clock);
+ });
+ });
+ });
+
+ return {
+ id,
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ prefixEl,
+ suffixEl,
+ focus,
+ onInput,
+ onKeydown,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ztzhwixg {
+ position: relative;
+
+ > .icon {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 24px;
+ text-align: center;
+ line-height: 32px;
+
+ &:not(:empty) + .input {
+ margin-left: 28px;
+ }
+ }
+
+ > .input {
+ $height: 48px;
+ position: relative;
+
+ > input {
+ display: block;
+ height: $height;
+ width: 100%;
+ margin: 0;
+ padding: 0 16px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ line-height: $height;
+ 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;
+ padding: 0 16px;
+ font-size: 1em;
+ line-height: $height;
+ 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: 8px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 8px;
+ }
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/key-value-view.vue b/packages/client/src/components/debobigego/key-value-view.vue
new file mode 100644
index 0000000000..0e034a2d54
--- /dev/null
+++ b/packages/client/src/components/debobigego/key-value-view.vue
@@ -0,0 +1,38 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoPanel anocepby">
+ <span class="key"><slot name="key"></slot></span>
+ <span class="value"><slot name="value"></slot></span>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+
+});
+</script>
+
+<style lang="scss" scoped>
+.anocepby {
+ display: flex;
+ align-items: center;
+ padding: 14px var(--debobigegoContentHMargin);
+
+ > .key {
+ margin-right: 12px;
+ white-space: nowrap;
+ }
+
+ > .value {
+ margin-left: auto;
+ opacity: 0.7;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/link.vue b/packages/client/src/components/debobigego/link.vue
new file mode 100644
index 0000000000..885579eadf
--- /dev/null
+++ b/packages/client/src/components/debobigego/link.vue
@@ -0,0 +1,103 @@
+<template>
+<div class="qmfkfnzi _debobigegoItem">
+ <a class="main _button _debobigegoPanel _debobigegoClickable" :href="to" target="_blank" v-if="external">
+ <span class="icon"><slot name="icon"></slot></span>
+ <span class="text"><slot></slot></span>
+ <span class="right">
+ <span class="text"><slot name="suffix"></slot></span>
+ <i class="fas fa-external-link-alt icon"></i>
+ </span>
+ </a>
+ <MkA class="main _button _debobigegoPanel _debobigegoClickable" :class="{ active }" :to="to" :behavior="behavior" v-else>
+ <span class="icon"><slot name="icon"></slot></span>
+ <span class="text"><slot></slot></span>
+ <span class="right">
+ <span class="text"><slot name="suffix"></slot></span>
+ <i class="fas fa-chevron-right icon"></i>
+ </span>
+ </MkA>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ to: {
+ type: String,
+ required: true
+ },
+ active: {
+ type: Boolean,
+ required: false
+ },
+ external: {
+ type: Boolean,
+ required: false
+ },
+ behavior: {
+ type: String,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qmfkfnzi {
+ > .main {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 14px 16px 14px 14px;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--panelHighlight);
+ }
+
+ > .icon {
+ width: 32px;
+ margin-right: 2px;
+ flex-shrink: 0;
+ text-align: center;
+ opacity: 0.8;
+
+ &:empty {
+ display: none;
+
+ & + .text {
+ padding-left: 4px;
+ }
+ }
+ }
+
+ > .text {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-right: 12px;
+ }
+
+ > .right {
+ margin-left: auto;
+ opacity: 0.7;
+
+ > .text:not(:empty) {
+ margin-right: 0.75em;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/object-view.vue b/packages/client/src/components/debobigego/object-view.vue
new file mode 100644
index 0000000000..ea79daa915
--- /dev/null
+++ b/packages/client/src/components/debobigego/object-view.vue
@@ -0,0 +1,102 @@
+<template>
+<FormGroup class="_debobigegoItem">
+ <template #label><slot></slot></template>
+ <div class="drooglns _debobigegoItem" :class="{ tall }">
+ <div class="input _debobigegoPanel">
+ <textarea class="_monospace"
+ v-model="v"
+ readonly
+ :spellcheck="false"
+ ></textarea>
+ </div>
+ </div>
+ <template #caption><slot name="desc"></slot></template>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, toRefs, watch } from 'vue';
+import * as JSON5 from 'json5';
+import './debobigego.scss';
+import FormGroup from './group.vue';
+
+export default defineComponent({
+ components: {
+ FormGroup,
+ },
+ props: {
+ value: {
+ required: false
+ },
+ tall: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ pre: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ setup(props, context) {
+ const { value } = toRefs(props);
+ const v = ref('');
+
+ watch(() => value, newValue => {
+ v.value = JSON5.stringify(newValue.value, null, '\t');
+ }, {
+ immediate: true
+ });
+
+ return {
+ v,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.drooglns {
+ position: relative;
+
+ > .input {
+ position: relative;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 16px var(--debobigegoContentHMargin);
+ box-sizing: border-box;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ color: var(--fg);
+ tab-size: 2;
+ white-space: pre;
+ }
+ }
+
+ &.tall {
+ > .input {
+ > textarea {
+ min-height: 200px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/pagination.vue b/packages/client/src/components/debobigego/pagination.vue
new file mode 100644
index 0000000000..07012cb759
--- /dev/null
+++ b/packages/client/src/components/debobigego/pagination.vue
@@ -0,0 +1,42 @@
+<template>
+<FormGroup class="uljviswt _debobigegoItem">
+ <template #label><slot name="label"></slot></template>
+ <slot :items="items"></slot>
+ <div class="empty" v-if="empty" key="_empty_">
+ <slot name="empty"></slot>
+ </div>
+ <FormButton v-show="more" class="button" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </FormButton>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
+import paging from '@/scripts/paging';
+
+export default defineComponent({
+ components: {
+ FormButton,
+ FormGroup,
+ },
+
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.uljviswt {
+}
+</style>
diff --git a/packages/client/src/components/debobigego/radios.vue b/packages/client/src/components/debobigego/radios.vue
new file mode 100644
index 0000000000..b4c5841337
--- /dev/null
+++ b/packages/client/src/components/debobigego/radios.vue
@@ -0,0 +1,112 @@
+<script lang="ts">
+import { defineComponent, h } from 'vue';
+import MkRadio from '@/components/form/radio.vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ components: {
+ MkRadio
+ },
+ props: {
+ modelValue: {
+ required: false
+ },
+ },
+ data() {
+ return {
+ value: this.modelValue,
+ }
+ },
+ watch: {
+ modelValue() {
+ this.value = this.modelValue;
+ },
+ value() {
+ this.$emit('update:modelValue', this.value);
+ }
+ },
+ render() {
+ const label = this.$slots.desc();
+ let options = this.$slots.default();
+
+ // なぜかFragmentになることがあるため
+ if (options.length === 1 && options[0].props == null) options = options[0].children;
+
+ return h('div', {
+ class: 'cnklmpwm _debobigegoItem'
+ }, [
+ h('div', {
+ class: '_debobigegoLabel',
+ }, label),
+ ...options.map(option => h('button', {
+ class: '_button _debobigegoPanel _debobigegoClickable',
+ key: option.key,
+ onClick: () => this.value = option.props.value,
+ }, [h('span', {
+ class: ['check', { checked: this.value === option.props.value }],
+ }), option.children]))
+ ]);
+ }
+});
+</script>
+
+<style lang="scss">
+.cnklmpwm {
+ > button {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 14px 18px;
+ text-align: left;
+
+ &:not(:first-of-type) {
+ border-top: none !important;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ &:not(:last-of-type) {
+ border-bottom: solid 0.5px var(--divider);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ > .check {
+ display: inline-block;
+ vertical-align: bottom;
+ position: relative;
+ width: 16px;
+ height: 16px;
+ margin-right: 8px;
+ background: none;
+ border: 2px solid var(--inputBorder);
+ 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: .4s cubic-bezier(.25,.8,.25,1);
+ }
+
+ &.checked {
+ border-color: var(--accent);
+
+ &:after {
+ background-color: var(--accent);
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/range.vue b/packages/client/src/components/debobigego/range.vue
new file mode 100644
index 0000000000..26fb0f37c6
--- /dev/null
+++ b/packages/client/src/components/debobigego/range.vue
@@ -0,0 +1,122 @@
+<template>
+<div class="ifitouly _debobigegoItem" :class="{ focused, disabled }">
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <div class="_debobigegoPanel main">
+ <input
+ type="range"
+ ref="input"
+ v-model="v"
+ :disabled="disabled"
+ :min="min"
+ :max="max"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @input="$emit('update:value', $event.target.value)"
+ />
+ </div>
+ <div class="_debobigegoCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ value: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ min: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ max: {
+ type: Number,
+ required: false,
+ default: 100
+ },
+ step: {
+ type: Number,
+ required: false,
+ default: 1
+ },
+ },
+ data() {
+ return {
+ v: this.value,
+ focused: false
+ };
+ },
+ watch: {
+ value(v) {
+ this.v = parseFloat(v);
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ifitouly {
+ position: relative;
+
+ > .main {
+ padding: 22px 16px;
+
+ > input {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background: var(--X10);
+ height: 4px;
+ width: 100%;
+ box-sizing: border-box;
+ margin: 0;
+ outline: 0;
+ border: 0;
+ border-radius: 7px;
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ box-sizing: content-box;
+ }
+
+ &::-moz-range-thumb {
+ -moz-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/select.vue b/packages/client/src/components/debobigego/select.vue
new file mode 100644
index 0000000000..7a31371afc
--- /dev/null
+++ b/packages/client/src/components/debobigego/select.vue
@@ -0,0 +1,145 @@
+<template>
+<div class="yrtfrpux _debobigegoItem" :class="{ disabled, inline }">
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <div class="icon" ref="icon"><slot name="icon"></slot></div>
+ <div class="input _debobigegoPanel _debobigegoClickable" @click="focus">
+ <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">
+ <i class="fas fa-chevron-down"></i>
+ </div>
+ </div>
+ <div class="_debobigegoCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ required: false
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ required: false
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ data() {
+ return {
+ };
+ },
+ computed: {
+ v: {
+ get() {
+ return this.modelValue;
+ },
+ set(v) {
+ this.$emit('update:modelValue', v);
+ }
+ },
+ },
+ methods: {
+ focus() {
+ this.$refs.input.focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yrtfrpux {
+ position: relative;
+
+ > .icon {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 24px;
+ text-align: center;
+ line-height: 32px;
+
+ &:not(:empty) + .input {
+ margin-left: 28px;
+ }
+ }
+
+ > .input {
+ display: flex;
+ position: relative;
+
+ > select {
+ display: block;
+ flex: 1;
+ width: 100%;
+ padding: 0 16px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ height: 48px;
+ background: none;
+ border: none;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ appearance: none;
+ -webkit-appearance: none;
+ color: var(--fg);
+
+ option,
+ optgroup {
+ color: var(--fg);
+ background: var(--bg);
+ }
+ }
+
+ > .prefix,
+ > .suffix {
+ display: block;
+ align-self: center;
+ justify-self: center;
+ font-size: 1em;
+ line-height: 32px;
+ color: var(--inputLabel);
+ pointer-events: none;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ display: block;
+ min-width: 16px;
+ }
+ }
+
+ > .prefix {
+ padding-right: 4px;
+ }
+
+ > .suffix {
+ padding: 0 16px 0 0;
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/suspense.vue b/packages/client/src/components/debobigego/suspense.vue
new file mode 100644
index 0000000000..b5ba63b4b5
--- /dev/null
+++ b/packages/client/src/components/debobigego/suspense.vue
@@ -0,0 +1,101 @@
+<template>
+<transition name="fade" mode="out-in">
+ <div class="_debobigegoItem" v-if="pending">
+ <div class="_debobigegoPanel">
+ <MkLoading/>
+ </div>
+ </div>
+ <div v-else-if="resolved" class="_debobigegoItem">
+ <slot :result="result"></slot>
+ </div>
+ <div class="_debobigegoItem" v-else>
+ <div class="_debobigegoPanel eiurkvay">
+ <div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div>
+ <MkButton inline @click="retry" class="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, ref, watch } from 'vue';
+import './debobigego.scss';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ p: {
+ type: Function as PropType<() => Promise<any>>,
+ required: true,
+ }
+ },
+
+ setup(props, context) {
+ const pending = ref(true);
+ const resolved = ref(false);
+ const rejected = ref(false);
+ const result = ref(null);
+
+ const process = () => {
+ if (props.p == null) {
+ return;
+ }
+ const promise = props.p();
+ pending.value = true;
+ resolved.value = false;
+ rejected.value = false;
+ promise.then((_result) => {
+ pending.value = false;
+ resolved.value = true;
+ result.value = _result;
+ });
+ promise.catch(() => {
+ pending.value = false;
+ rejected.value = true;
+ });
+ };
+
+ watch(() => props.p, () => {
+ process();
+ }, {
+ immediate: true
+ });
+
+ const retry = () => {
+ process();
+ };
+
+ return {
+ pending,
+ resolved,
+ rejected,
+ result,
+ retry,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.eiurkvay {
+ padding: 16px;
+ text-align: center;
+
+ > .retry {
+ margin-top: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/switch.vue b/packages/client/src/components/debobigego/switch.vue
new file mode 100644
index 0000000000..9a69e18302
--- /dev/null
+++ b/packages/client/src/components/debobigego/switch.vue
@@ -0,0 +1,132 @@
+<template>
+<div class="ijnpvmgr _debobigegoItem">
+ <div class="main _debobigegoPanel _debobigegoClickable"
+ :class="{ disabled, checked }"
+ :aria-checked="checked"
+ :aria-disabled="disabled"
+ @click.prevent="toggle"
+ >
+ <input
+ type="checkbox"
+ ref="input"
+ :disabled="disabled"
+ @keydown.enter="toggle"
+ >
+ <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff">
+ <span class="handle"></span>
+ </span>
+ <span class="label">
+ <span><slot></slot></span>
+ </span>
+ </div>
+ <div class="_debobigegoCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ type: Boolean,
+ default: false
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ checked(): boolean {
+ return this.modelValue;
+ }
+ },
+ methods: {
+ toggle() {
+ if (this.disabled) return;
+ this.$emit('update:modelValue', !this.checked);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ijnpvmgr {
+ > .main {
+ position: relative;
+ display: flex;
+ padding: 14px 16px;
+ cursor: pointer;
+
+ > * {
+ user-select: none;
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ margin: 0;
+ width: 34px;
+ height: 22px;
+ background: var(--switchBg);
+ outline: none;
+ border-radius: 999px;
+ transition: all 0.3s;
+ cursor: pointer;
+
+ > .handle {
+ position: absolute;
+ top: 0;
+ left: 3px;
+ bottom: 0;
+ margin: auto 0;
+ border-radius: 100%;
+ transition: background-color 0.3s, transform 0.3s;
+ width: 16px;
+ height: 16px;
+ background-color: #fff;
+ pointer-events: none;
+ }
+ }
+
+ > .label {
+ margin-left: 12px;
+ display: block;
+ transition: inherit;
+ color: var(--fg);
+
+ > span {
+ display: block;
+ line-height: 20px;
+ transition: inherit;
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &.checked {
+ > .button {
+ background-color: var(--accent);
+
+ > .handle {
+ transform: translateX(12px);
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/textarea.vue b/packages/client/src/components/debobigego/textarea.vue
new file mode 100644
index 0000000000..64e8d47126
--- /dev/null
+++ b/packages/client/src/components/debobigego/textarea.vue
@@ -0,0 +1,161 @@
+<template>
+<FormGroup class="_debobigegoItem">
+ <template #label><slot></slot></template>
+ <div class="rivhosbp _debobigegoItem" :class="{ tall, pre }">
+ <div class="input _debobigegoPanel">
+ <textarea ref="input" :class="{ code, _monospace: code }"
+ v-model="v"
+ :required="required"
+ :readonly="readonly"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="!code"
+ @input="onInput"
+ @focus="focused = true"
+ @blur="focused = false"
+ ></textarea>
+ </div>
+ </div>
+ <template #caption><slot name="desc"></slot></template>
+
+ <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, toRefs, watch } from 'vue';
+import './debobigego.scss';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
+
+export default defineComponent({
+ components: {
+ FormGroup,
+ FormButton,
+ },
+ props: {
+ modelValue: {
+ required: false
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ readonly: {
+ type: Boolean,
+ required: false
+ },
+ pattern: {
+ type: String,
+ required: false
+ },
+ autocomplete: {
+ type: String,
+ required: false
+ },
+ code: {
+ type: Boolean,
+ required: false
+ },
+ tall: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ pre: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ setup(props, context) {
+ const { modelValue } = toRefs(props);
+ const v = ref(modelValue.value);
+ const changed = ref(false);
+ const inputEl = ref(null);
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+
+ const updated = () => {
+ changed.value = false;
+ context.emit('update:modelValue', v.value);
+ };
+
+ watch(modelValue.value, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ updated();
+ }
+ });
+
+ return {
+ v,
+ updated,
+ changed,
+ focus,
+ onInput,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rivhosbp {
+ position: relative;
+
+ > .input {
+ position: relative;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 16px;
+ box-sizing: border-box;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ color: var(--fg);
+
+ &.code {
+ tab-size: 2;
+ }
+ }
+ }
+
+ &.tall {
+ > .input {
+ > textarea {
+ min-height: 200px;
+ }
+ }
+ }
+
+ &.pre {
+ > .input {
+ > textarea {
+ white-space: pre;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/tuple.vue b/packages/client/src/components/debobigego/tuple.vue
new file mode 100644
index 0000000000..8a4599fd64
--- /dev/null
+++ b/packages/client/src/components/debobigego/tuple.vue
@@ -0,0 +1,36 @@
+<template>
+<div class="wthhikgt _debobigegoItem" v-size="{ max: [500] }">
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+});
+</script>
+
+<style lang="scss" scoped>
+.wthhikgt {
+ position: relative;
+ display: flex;
+
+ > ::v-deep(*) {
+ flex: 1;
+ margin: 0;
+
+ &:not(:last-child) {
+ margin-right: 16px;
+ }
+ }
+
+ &.max-width_500px {
+ display: block;
+
+ > ::v-deep(*) {
+ margin: inherit;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue
new file mode 100644
index 0000000000..90086fd430
--- /dev/null
+++ b/packages/client/src/components/dialog.vue
@@ -0,0 +1,212 @@
+<template>
+<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')">
+ <div class="mk-dialog">
+ <div class="icon" v-if="icon">
+ <i :class="icon"></i>
+ </div>
+ <div class="icon" v-else-if="!input && !select" :class="type">
+ <i v-if="type === 'success'" class="fas fa-check"></i>
+ <i v-else-if="type === 'error'" class="fas fa-times-circle"></i>
+ <i v-else-if="type === 'warning'" class="fas fa-exclamation-triangle"></i>
+ <i v-else-if="type === 'info'" class="fas fa-info-circle"></i>
+ <i v-else-if="type === 'question'" class="fas fa-question-circle"></i>
+ <i v-else-if="type === 'waiting'" class="fas fa-spinner fa-pulse"></i>
+ </div>
+ <header v-if="title"><Mfm :text="title"/></header>
+ <div class="body" v-if="text"><Mfm :text="text"/></div>
+ <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></MkInput>
+ <MkSelect 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>
+ </MkSelect>
+ <div class="buttons" v-if="(showOkButton || showCancelButton) && !actions">
+ <MkButton inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select">{{ (showCancelButton || input || select) ? $ts.ok : $ts.gotIt }}</MkButton>
+ <MkButton inline @click="cancel" v-if="showCancelButton || input || select">{{ $ts.cancel }}</MkButton>
+ </div>
+ <div class="buttons" v-if="actions">
+ <MkButton v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</MkButton>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ 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
+ },
+ icon: {
+ required: false
+ },
+ actions: {
+ required: false
+ },
+ showOkButton: {
+ type: Boolean,
+ default: true
+ },
+ showCancelButton: {
+ type: Boolean,
+ default: false
+ },
+ cancelableByBgClick: {
+ type: Boolean,
+ default: true
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ inputValue: this.input && this.input.default ? this.input.default : 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,
+ };
+ },
+
+ mounted() {
+ document.addEventListener('keydown', this.onKeydown);
+ },
+
+ beforeUnmount() {
+ document.removeEventListener('keydown', this.onKeydown);
+ },
+
+ methods: {
+ done(canceled, result?) {
+ this.$emit('done', { canceled, result });
+ this.$refs.modal.close();
+ },
+
+ async ok() {
+ if (!this.showOkButton) return;
+
+ const result =
+ this.input ? this.inputValue :
+ this.select ? this.selectedValue :
+ true;
+ this.done(false, result);
+ },
+
+ cancel() {
+ this.done(true);
+ },
+
+ 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>
+.mk-dialog {
+ position: relative;
+ padding: 32px;
+ min-width: 320px;
+ max-width: 480px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .icon {
+ font-size: 32px;
+
+ &.success {
+ color: var(--success);
+ }
+
+ &.error {
+ color: var(--error);
+ }
+
+ &.warning {
+ color: var(--warn);
+ }
+
+ > * {
+ 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/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue
new file mode 100644
index 0000000000..9b6a0c9a0d
--- /dev/null
+++ b/packages/client/src/components/drive-file-thumbnail.vue
@@ -0,0 +1,108 @@
+<template>
+<div class="zdjebgpv" ref="thumbnail">
+ <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/>
+ <i v-else-if="is === 'image'" class="fas fa-file-image icon"></i>
+ <i v-else-if="is === 'video'" class="fas fa-file-video icon"></i>
+ <i v-else-if="is === 'audio' || is === 'midi'" class="fas fa-music icon"></i>
+ <i v-else-if="is === 'csv'" class="fas fa-file-csv icon"></i>
+ <i v-else-if="is === 'pdf'" class="fas fa-file-pdf icon"></i>
+ <i v-else-if="is === 'textfile'" class="fas fa-file-alt icon"></i>
+ <i v-else-if="is === 'archive'" class="fas fa-file-archive icon"></i>
+ <i v-else class="fas fa-file icon"></i>
+
+ <i v-if="isThumbnailAvailable && is === 'video'" class="fas fa-film icon-sub"></i>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+ components: {
+ ImgWithBlurhash
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true
+ },
+ fit: {
+ type: String,
+ required: false,
+ default: 'cover'
+ },
+ },
+ data() {
+ return {
+ isContextmenuShowing: false,
+ isDragging: false,
+
+ };
+ },
+ 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;
+ },
+ },
+ mounted() {
+ const audioTag = this.$refs.volumectrl as HTMLAudioElement;
+ if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume');
+ },
+ methods: {
+ volumechange() {
+ const audioTag = this.$refs.volumectrl as HTMLAudioElement;
+ ColdDeviceStorage.set('mediaVolume', audioTag.volume);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zdjebgpv {
+ position: relative;
+
+ > .icon-sub {
+ position: absolute;
+ width: 30%;
+ height: auto;
+ margin: 0;
+ right: 4%;
+ bottom: 4%;
+ }
+
+ > * {
+ margin: auto;
+ }
+
+ > .icon {
+ pointer-events: none;
+ height: 65%;
+ width: 65%;
+ }
+}
+</style>
diff --git a/packages/client/src/components/drive-select-dialog.vue b/packages/client/src/components/drive-select-dialog.vue
new file mode 100644
index 0000000000..f9a4025452
--- /dev/null
+++ b/packages/client/src/components/drive-select-dialog.vue
@@ -0,0 +1,70 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="800"
+ :height="500"
+ :with-ok-button="true"
+ :ok-button-disabled="(type === 'file') && (selected.length === 0)"
+ @click="cancel()"
+ @close="cancel()"
+ @ok="ok()"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ {{ multiple ? ((type === 'file') ? $ts.selectFiles : $ts.selectFolders) : ((type === 'file') ? $ts.selectFile : $ts.selectFolder) }}
+ <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
+ </template>
+ <XDrive :multiple="multiple" @changeSelection="onChangeSelection" @selected="ok()" :select="type"/>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XDrive from './drive.vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ XDrive,
+ XModalWindow,
+ },
+
+ props: {
+ type: {
+ type: String,
+ required: false,
+ default: 'file'
+ },
+ multiple: {
+ type: Boolean,
+ default: false
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ selected: []
+ };
+ },
+
+ methods: {
+ ok() {
+ this.$emit('done', this.selected);
+ this.$refs.dialog.close();
+ },
+
+ cancel() {
+ this.$emit('done');
+ this.$refs.dialog.close();
+ },
+
+ onChangeSelection(xs) {
+ this.selected = xs;
+ },
+
+ number
+ }
+});
+</script>
diff --git a/packages/client/src/components/drive-window.vue b/packages/client/src/components/drive-window.vue
new file mode 100644
index 0000000000..43f07ebe76
--- /dev/null
+++ b/packages/client/src/components/drive-window.vue
@@ -0,0 +1,44 @@
+<template>
+<XWindow ref="window"
+ :initial-width="800"
+ :initial-height="500"
+ :can-resize="true"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ {{ $ts.drive }}
+ </template>
+ <XDrive :initial-folder="initialFolder"/>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XDrive from './drive.vue';
+import XWindow from '@/components/ui/window.vue';
+
+export default defineComponent({
+ components: {
+ XDrive,
+ XWindow,
+ },
+
+ props: {
+ initialFolder: {
+ type: Object,
+ required: false
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+
+ }
+});
+</script>
diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/drive.file.vue
new file mode 100644
index 0000000000..86f4ee0f8d
--- /dev/null
+++ b/packages/client/src/components/drive.file.vue
@@ -0,0 +1,374 @@
+<template>
+<div class="ncvczrfv"
+ :class="{ isSelected }"
+ @click="onClick"
+ @contextmenu.stop="onContextmenu"
+ draggable="true"
+ @dragstart="onDragstart"
+ @dragend="onDragend"
+ :title="title"
+>
+ <div class="label" v-if="$i.avatarId == file.id">
+ <img src="/client-assets/label.svg"/>
+ <p>{{ $ts.avatar }}</p>
+ </div>
+ <div class="label" v-if="$i.bannerId == file.id">
+ <img src="/client-assets/label.svg"/>
+ <p>{{ $ts.banner }}</p>
+ </div>
+ <div class="label red" v-if="file.isSensitive">
+ <img src="/client-assets/label-red.svg"/>
+ <p>{{ $ts.nsfw }}</p>
+ </div>
+
+ <MkDriveFileThumbnail 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 { defineComponent } from 'vue';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import MkDriveFileThumbnail from './drive-file-thumbnail.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkDriveFileThumbnail
+ },
+
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ isSelected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ isDragging: false
+ };
+ },
+
+ computed: {
+ // TODO: parentへの参照を無くす
+ browser(): any {
+ return this.$parent;
+ },
+ title(): string {
+ return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`;
+ }
+ },
+
+ methods: {
+ getMenu() {
+ return [{
+ text: this.$ts.rename,
+ icon: 'fas fa-i-cursor',
+ action: this.rename
+ }, {
+ text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
+ icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash',
+ action: this.toggleSensitive
+ }, {
+ text: this.$ts.describeFile,
+ icon: 'fas fa-i-cursor',
+ action: this.describe
+ }, null, {
+ text: this.$ts.copyUrl,
+ icon: 'fas fa-link',
+ action: this.copyUrl
+ }, {
+ type: 'a',
+ href: this.file.url,
+ target: '_blank',
+ text: this.$ts.download,
+ icon: 'fas fa-download',
+ download: this.file.name
+ }, null, {
+ text: this.$ts.delete,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: this.deleteFile
+ }];
+ },
+
+ onClick(ev) {
+ if (this.selectMode) {
+ this.$emit('chosen', this.file);
+ } else {
+ os.popupMenu(this.getMenu(), ev.currentTarget || ev.target);
+ }
+ },
+
+ onContextmenu(e) {
+ os.contextMenu(this.getMenu(), e);
+ },
+
+ onDragstart(e) {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file));
+ this.isDragging = true;
+
+ // 親ブラウザに対して、ドラッグが開始されたフラグを立てる
+ // (=あなたの子供が、ドラッグを開始しましたよ)
+ this.browser.isDragSource = true;
+ },
+
+ onDragend(e) {
+ this.isDragging = false;
+ this.browser.isDragSource = false;
+ },
+
+ rename() {
+ os.dialog({
+ title: this.$ts.renameFile,
+ input: {
+ placeholder: this.$ts.inputNewFileName,
+ default: this.file.name,
+ allowEmpty: false
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/files/update', {
+ fileId: this.file.id,
+ name: name
+ });
+ });
+ },
+
+ describe() {
+ os.popup(import('@/components/media-caption.vue'), {
+ title: this.$ts.describeFile,
+ input: {
+ placeholder: this.$ts.inputNewDescription,
+ default: this.file.comment !== null ? this.file.comment : '',
+ },
+ image: this.file
+ }, {
+ done: result => {
+ if (!result || result.canceled) return;
+ let comment = result.result;
+ os.api('drive/files/update', {
+ fileId: this.file.id,
+ comment: comment.length == 0 ? null : comment
+ });
+ }
+ }, 'closed');
+ },
+
+ toggleSensitive() {
+ os.api('drive/files/update', {
+ fileId: this.file.id,
+ isSensitive: !this.file.isSensitive
+ });
+ },
+
+ copyUrl() {
+ copyToClipboard(this.file.url);
+ os.success();
+ },
+
+ setAsAvatar() {
+ os.updateAvatar(this.file);
+ },
+
+ setAsBanner() {
+ os.updateBanner(this.file);
+ },
+
+ addApp() {
+ alert('not implemented yet');
+ },
+
+ async deleteFile() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('driveFileDeleteConfirm', { name: this.file.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.api('drive/files/delete', {
+ fileId: this.file.id
+ });
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ncvczrfv {
+ position: relative;
+ padding: 8px 0 0 0;
+ min-height: 180px;
+ border-radius: 4px;
+
+ &, * {
+ cursor: pointer;
+ }
+
+ > * {
+ pointer-events: none;
+ }
+
+ &: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;
+ }
+ }
+ }
+ }
+
+ &.isSelected {
+ 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: 110px;
+ height: 110px;
+ margin: auto;
+ }
+
+ > .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/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue
new file mode 100644
index 0000000000..91e27cc8a1
--- /dev/null
+++ b/packages/client/src/components/drive.folder.vue
@@ -0,0 +1,326 @@
+<template>
+<div class="rghtznwe"
+ :class="{ draghover }"
+ @click="onClick"
+ @contextmenu.stop="onContextmenu"
+ @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"><i class="fas fa-folder-open fa-fw"></i></template>
+ <template v-if="!hover"><i class="fas fa-folder fa-fw"></i></template>
+ {{ folder.name }}
+ </p>
+ <p class="upload" v-if="$store.state.uploadFolder == folder.id">
+ {{ $ts.uploadFolder }}
+ </p>
+ <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ folder: {
+ type: Object,
+ required: true,
+ },
+ isSelected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ hover: false,
+ draghover: false,
+ isDragging: false,
+ };
+ },
+
+ computed: {
+ browser(): any {
+ return this.$parent;
+ },
+ title(): string {
+ return this.folder.name;
+ }
+ },
+
+ methods: {
+ checkboxClicked(e) {
+ this.$emit('chosen', this.folder);
+ },
+
+ 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] == _DATA_TRANSFER_DRIVE_FILE_;
+ const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_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(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.browser.removeFile(file.id);
+ os.api('drive/files/update', {
+ fileId: file.id,
+ folderId: this.folder.id
+ });
+ }
+ //#endregion
+
+ //#region ドライブのフォルダ
+ const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
+ if (driveFolder != null && driveFolder != '') {
+ const folder = JSON.parse(driveFolder);
+
+ // 移動先が自分自身ならreject
+ if (folder.id == this.folder.id) return;
+
+ this.browser.removeFolder(folder.id);
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ parentId: this.folder.id
+ }).then(() => {
+ // noop
+ }).catch(err => {
+ switch (err) {
+ case 'detected-circular-definition':
+ os.dialog({
+ title: this.$ts.unableToProcess,
+ text: this.$ts.circularReferenceFolder
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.somethingHappened
+ });
+ }
+ });
+ }
+ //#endregion
+ },
+
+ onDragstart(e) {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData(_DATA_TRANSFER_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() {
+ os.dialog({
+ title: this.$ts.renameFolder,
+ input: {
+ placeholder: this.$ts.inputNewFolderName,
+ default: this.folder.name
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/folders/update', {
+ folderId: this.folder.id,
+ name: name
+ });
+ });
+ },
+
+ deleteFolder() {
+ os.api('drive/folders/delete', {
+ folderId: this.folder.id
+ }).then(() => {
+ if (this.$store.state.uploadFolder === this.folder.id) {
+ this.$store.set('uploadFolder', null);
+ }
+ }).catch(err => {
+ switch(err.id) {
+ case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
+ os.dialog({
+ type: 'error',
+ title: this.$ts.unableToDelete,
+ text: this.$ts.hasChildFilesOrFolders
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.unableToDelete
+ });
+ }
+ });
+ },
+
+ setAsUploadFolder() {
+ this.$store.set('uploadFolder', this.folder.id);
+ },
+
+ onContextmenu(e) {
+ os.contextMenu([{
+ text: this.$ts.openInWindow,
+ icon: 'fas fa-window-restore',
+ action: () => {
+ os.popup(import('./drive-window.vue'), {
+ initialFolder: this.folder
+ }, {
+ }, 'closed');
+ }
+ }, null, {
+ text: this.$ts.rename,
+ icon: 'fas fa-i-cursor',
+ action: this.rename
+ }, null, {
+ text: this.$ts.delete,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: this.deleteFolder
+ }], e);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rghtznwe {
+ position: relative;
+ padding: 8px;
+ height: 64px;
+ background: var(--driveFolderBg);
+ border-radius: 4px;
+
+ &, * {
+ cursor: pointer;
+ }
+
+ *:not(.checkbox) {
+ pointer-events: none;
+ }
+
+ > .checkbox {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ width: 16px;
+ height: 16px;
+ background: #fff;
+ border: solid 1px #000;
+
+ &.checked {
+ background: var(--accent);
+ }
+ }
+
+ &.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);
+
+ > i {
+ 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/packages/client/src/components/drive.nav-folder.vue b/packages/client/src/components/drive.nav-folder.vue
new file mode 100644
index 0000000000..4f0e6ce0e9
--- /dev/null
+++ b/packages/client/src/components/drive.nav-folder.vue
@@ -0,0 +1,135 @@
+<template>
+<div class="drylbebk"
+ :class="{ draghover }"
+ @click="onClick"
+ @dragover.prevent.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.stop="onDrop"
+>
+ <i v-if="folder == null" class="fas fa-cloud"></i>
+ <span>{{ folder == null ? $ts.drive : folder.name }}</span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ folder: {
+ type: Object,
+ required: false,
+ }
+ },
+
+ data() {
+ return {
+ hover: false,
+ draghover: false,
+ };
+ },
+
+ 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] == _DATA_TRANSFER_DRIVE_FILE_;
+ const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_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(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.browser.removeFile(file.id);
+ os.api('drive/files/update', {
+ fileId: file.id,
+ folderId: this.folder ? this.folder.id : null
+ });
+ }
+ //#endregion
+
+ //#region ドライブのフォルダ
+ const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_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);
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ parentId: this.folder ? this.folder.id : null
+ });
+ }
+ //#endregion
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.drylbebk {
+ > * {
+ pointer-events: none;
+ }
+
+ &.draghover {
+ background: #eee;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue
new file mode 100644
index 0000000000..2b72a0a1c6
--- /dev/null
+++ b/packages/client/src/components/drive.vue
@@ -0,0 +1,784 @@
+<template>
+<div class="yfudmmck">
+ <nav>
+ <div class="path" @contextmenu.prevent.stop="() => {}">
+ <XNavFolder :class="{ current: folder == null }"/>
+ <template v-for="f in hierarchyFolders">
+ <span class="separator"><i class="fas fa-angle-right"></i></span>
+ <XNavFolder :folder="f"/>
+ </template>
+ <span class="separator" v-if="folder != null"><i class="fas fa-angle-right"></i></span>
+ <span class="folder current" v-if="folder != null">{{ folder.name }}</span>
+ </div>
+ <button @click="showMenu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button>
+ </nav>
+ <div class="main" :class="{ uploading: uploadings.length > 0, fetching }"
+ ref="main"
+ @dragover.prevent.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.prevent.stop="onDrop"
+ @contextmenu.stop="onContextmenu"
+ >
+ <div class="contents" ref="contents">
+ <div class="folders" ref="foldersContainer" v-show="folders.length > 0">
+ <XFolder v-for="(f, i) in folders" :key="f.id" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" v-anim="i"/>
+ <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
+ <div class="padding" v-for="(n, i) in 16" :key="i"></div>
+ <MkButton ref="moreFolders" v-if="moreFolders">{{ $ts.loadMore }}</MkButton>
+ </div>
+ <div class="files" ref="filesContainer" v-show="files.length > 0">
+ <XFile v-for="(file, i) in files" :key="file.id" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" v-anim="i"/>
+ <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
+ <div class="padding" v-for="(n, i) in 16" :key="i"></div>
+ <MkButton ref="loadMoreFiles" @click="fetchMoreFiles" v-show="moreFiles">{{ $ts.loadMore }}</MkButton>
+ </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>{{ $ts.emptyDrive }}</strong><br/>{{ $t('empty-drive-description') }}</p>
+ <p v-if="!draghover && folder != null">{{ $ts.emptyFolder }}</p>
+ </div>
+ </div>
+ <MkLoading v-if="fetching"/>
+ </div>
+ <div class="dropzone" v-if="draghover"></div>
+ <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XNavFolder from './drive.nav-folder.vue';
+import XFolder from './drive.folder.vue';
+import XFile from './drive.file.vue';
+import MkButton from './ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XNavFolder,
+ XFolder,
+ XFile,
+ MkButton,
+ },
+
+ props: {
+ initialFolder: {
+ type: Object,
+ required: false
+ },
+ type: {
+ type: String,
+ required: false,
+ default: undefined
+ },
+ multiple: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ select: {
+ type: String,
+ required: false,
+ default: null
+ }
+ },
+
+ emits: ['selected', 'change-selection', 'move-root', 'cd', 'open-folder'],
+
+ data() {
+ return {
+ /**
+ * 現在の階層(フォルダ)
+ * * null でルートを表す
+ */
+ folder: null,
+
+ files: [],
+ folders: [],
+ moreFiles: false,
+ moreFolders: false,
+ hierarchyFolders: [],
+ selectedFiles: [],
+ selectedFolders: [],
+ uploadings: os.uploads,
+ connection: null,
+
+ /**
+ * ドロップされようとしているか
+ */
+ draghover: false,
+
+ /**
+ * 自信の所有するアイテムがドラッグをスタートさせたか
+ * (自分自身の階層にドロップできないようにするためのフラグ)
+ */
+ isDragSource: false,
+
+ fetching: true,
+
+ ilFilesObserver: new IntersectionObserver(
+ (entries) => entries.some((entry) => entry.isIntersecting)
+ && !this.fetching && this.moreFiles &&
+ this.fetchMoreFiles()
+ ),
+ moreFilesElement: null as Element,
+
+ };
+ },
+
+ watch: {
+ folder() {
+ this.$emit('cd', this.folder);
+ }
+ },
+
+ mounted() {
+ if (this.$store.state.enableInfiniteScroll && this.$refs.loadMoreFiles) {
+ this.$nextTick(() => {
+ this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el)
+ });
+ }
+
+ this.connection = markRaw(os.stream.useChannel('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.initialFolder) {
+ this.move(this.initialFolder);
+ } else {
+ this.fetch();
+ }
+ },
+
+ activated() {
+ if (this.$store.state.enableInfiniteScroll) {
+ this.$nextTick(() => {
+ this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el)
+ });
+ }
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ this.ilFilesObserver.disconnect();
+ },
+
+ 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);
+ },
+
+ 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] == _DATA_TRANSFER_DRIVE_FILE_;
+ const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_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(_DATA_TRANSFER_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);
+ os.api('drive/files/update', {
+ fileId: file.id,
+ folderId: this.folder ? this.folder.id : null
+ });
+ }
+ //#endregion
+
+ //#region ドライブのフォルダ
+ const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_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);
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ parentId: this.folder ? this.folder.id : null
+ }).then(() => {
+ // noop
+ }).catch(err => {
+ switch (err) {
+ case 'detected-circular-definition':
+ os.dialog({
+ title: this.$ts.unableToProcess,
+ text: this.$ts.circularReferenceFolder
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.somethingHappened
+ });
+ }
+ });
+ }
+ //#endregion
+ },
+
+ selectLocalFile() {
+ (this.$refs.fileInput as any).click();
+ },
+
+ urlUpload() {
+ os.dialog({
+ title: this.$ts.uploadFromUrl,
+ input: {
+ placeholder: this.$ts.uploadFromUrlDescription
+ }
+ }).then(({ canceled, result: url }) => {
+ if (canceled) return;
+ os.api('drive/files/upload-from-url', {
+ url: url,
+ folderId: this.folder ? this.folder.id : undefined
+ });
+
+ os.dialog({
+ title: this.$ts.uploadFromUrlRequested,
+ text: this.$ts.uploadFromUrlMayTakeTime
+ });
+ });
+ },
+
+ createFolder() {
+ os.dialog({
+ title: this.$ts.createFolder,
+ input: {
+ placeholder: this.$ts.folderName
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/folders/create', {
+ name: name,
+ parentId: this.folder ? this.folder.id : undefined
+ }).then(folder => {
+ this.addFolder(folder, true);
+ });
+ });
+ },
+
+ renameFolder(folder) {
+ os.dialog({
+ title: this.$ts.renameFolder,
+ input: {
+ placeholder: this.$ts.inputNewFolderName,
+ default: folder.name
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ name: name
+ }).then(folder => {
+ // FIXME: 画面を更新するために自分自身に移動
+ this.move(folder);
+ });
+ });
+ },
+
+ deleteFolder(folder) {
+ os.api('drive/folders/delete', {
+ folderId: folder.id
+ }).then(() => {
+ // 削除時に親フォルダに移動
+ this.move(folder.parentId);
+ }).catch(err => {
+ switch(err.id) {
+ case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
+ os.dialog({
+ type: 'error',
+ title: this.$ts.unableToDelete,
+ text: this.$ts.hasChildFilesOrFolders
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.unableToDelete
+ });
+ }
+ });
+ },
+
+ 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;
+ os.upload(file, folder).then(res => {
+ this.addFile(res, true);
+ });
+ },
+
+ 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]);
+ }
+ }
+ },
+
+ chooseFolder(folder) {
+ const isAlreadySelected = this.selectedFolders.some(f => f.id == folder.id);
+ if (this.multiple) {
+ if (isAlreadySelected) {
+ this.selectedFolders = this.selectedFolders.filter(f => f.id != folder.id);
+ } else {
+ this.selectedFolders.push(folder);
+ }
+ this.$emit('change-selection', this.selectedFolders);
+ } else {
+ if (isAlreadySelected) {
+ this.$emit('selected', folder);
+ } else {
+ this.selectedFolders = [folder];
+ this.$emit('change-selection', [folder]);
+ }
+ }
+ },
+
+ move(target) {
+ if (target == null) {
+ this.goRoot();
+ return;
+ } else if (typeof target == 'object') {
+ target = target.id;
+ }
+
+ this.fetching = true;
+
+ os.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);
+ 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);
+ 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;
+
+ // フォルダ一覧取得
+ os.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();
+ });
+
+ // ファイル一覧取得
+ os.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;
+
+ // ファイル一覧取得
+ os.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;
+ });
+ },
+
+ getMenu() {
+ return [{
+ text: this.$ts.addFile,
+ type: 'label'
+ }, {
+ text: this.$ts.upload,
+ icon: 'fas fa-upload',
+ action: () => { this.selectLocalFile(); }
+ }, {
+ text: this.$ts.fromUrl,
+ icon: 'fas fa-link',
+ action: () => { this.urlUpload(); }
+ }, null, {
+ text: this.folder ? this.folder.name : this.$ts.drive,
+ type: 'label'
+ }, this.folder ? {
+ text: this.$ts.renameFolder,
+ icon: 'fas fa-i-cursor',
+ action: () => { this.renameFolder(this.folder); }
+ } : undefined, this.folder ? {
+ text: this.$ts.deleteFolder,
+ icon: 'fas fa-trash-alt',
+ action: () => { this.deleteFolder(this.folder); }
+ } : undefined, {
+ text: this.$ts.createFolder,
+ icon: 'fas fa-folder-plus',
+ action: () => { this.createFolder(); }
+ }];
+ },
+
+ showMenu(ev) {
+ os.popupMenu(this.getMenu(), ev.currentTarget || ev.target);
+ },
+
+ onContextmenu(ev) {
+ os.contextMenu(this.getMenu(), ev);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yfudmmck {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > nav {
+ display: flex;
+ z-index: 2;
+ width: 100%;
+ padding: 0 8px;
+ box-sizing: border-box;
+ 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;
+
+ > i {
+ margin: 0;
+ }
+ }
+ }
+ }
+
+ > .menu {
+ margin-left: auto;
+ }
+ }
+
+ > .main {
+ flex: 1;
+ overflow: auto;
+ padding: var(--margin);
+
+ &, * {
+ 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: 128px;
+ margin: 4px;
+ box-sizing: border-box;
+ }
+
+ > .padding {
+ flex-grow: 1;
+ pointer-events: none;
+ width: 128px + 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;
+ }
+
+ > input {
+ display: none;
+ }
+}
+</style>
diff --git a/packages/client/src/components/emoji-picker-dialog.vue b/packages/client/src/components/emoji-picker-dialog.vue
new file mode 100644
index 0000000000..1d48bbb8a3
--- /dev/null
+++ b/packages/client/src/components/emoji-picker-dialog.vue
@@ -0,0 +1,76 @@
+<template>
+<MkPopup ref="popup" :manual-showing="manualShowing" :src="src" :front="true" @click="$refs.popup.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')" #default="{point}">
+ <MkEmojiPicker class="ryghynhb _popup _shadow" :class="{ pointer: point === 'top' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen" ref="picker"/>
+</MkPopup>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkPopup from '@/components/ui/popup.vue';
+import MkEmojiPicker from '@/components/emoji-picker.vue';
+
+export default defineComponent({
+ components: {
+ MkPopup,
+ MkEmojiPicker,
+ },
+
+ props: {
+ manualShowing: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ src: {
+ required: false
+ },
+ showPinned: {
+ required: false,
+ default: true
+ },
+ asReactionPicker: {
+ required: false
+ },
+ },
+
+ emits: ['done', 'close', 'closed'],
+
+ data() {
+ return {
+
+ };
+ },
+
+ methods: {
+ chosen(emoji: any) {
+ this.$emit('done', emoji);
+ this.$refs.popup.close();
+ },
+
+ opening() {
+ this.$refs.picker.reset();
+ this.$refs.picker.focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ryghynhb {
+ &.pointer {
+ &:before {
+ --size: 8px;
+ content: '';
+ display: block;
+ position: absolute;
+ top: calc(0px - (var(--size) * 2));
+ left: 0;
+ right: 0;
+ width: 0;
+ margin: auto;
+ border: solid var(--size) transparent;
+ border-bottom-color: var(--popup);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/emoji-picker-window.vue b/packages/client/src/components/emoji-picker-window.vue
new file mode 100644
index 0000000000..0ffa0c1187
--- /dev/null
+++ b/packages/client/src/components/emoji-picker-window.vue
@@ -0,0 +1,197 @@
+<template>
+<MkWindow ref="window"
+ :initial-width="null"
+ :initial-height="null"
+ :can-resize="false"
+ :mini="true"
+ :front="true"
+ @closed="$emit('closed')"
+>
+ <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/>
+</MkWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkWindow from '@/components/ui/window.vue';
+import MkEmojiPicker from '@/components/emoji-picker.vue';
+
+export default defineComponent({
+ components: {
+ MkWindow,
+ MkEmojiPicker,
+ },
+
+ props: {
+ src: {
+ required: false
+ },
+ showPinned: {
+ required: false,
+ default: true
+ },
+ asReactionPicker: {
+ required: false
+ },
+ },
+
+ emits: ['chosen', 'closed'],
+
+ data() {
+ return {
+
+ };
+ },
+
+ methods: {
+ chosen(emoji: any) {
+ this.$emit('chosen', emoji);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.omfetrab {
+ $pad: 8px;
+ --eachSize: 40px;
+
+ display: flex;
+ flex-direction: column;
+ contain: content;
+
+ &.big {
+ --eachSize: 44px;
+ }
+
+ &.w1 {
+ width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
+ }
+
+ &.w2 {
+ width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.w3 {
+ width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
+ }
+
+ &.h1 {
+ --height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
+ }
+
+ &.h2 {
+ --height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.h3 {
+ --height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
+ }
+
+ > .search {
+ width: 100%;
+ padding: 12px;
+ box-sizing: border-box;
+ font-size: 1em;
+ outline: none;
+ border: none;
+ background: transparent;
+ color: var(--fg);
+
+ &:not(.filled) {
+ order: 1;
+ z-index: 2;
+ box-shadow: 0px -1px 0 0px var(--divider);
+ }
+ }
+
+ > .emojis {
+ height: var(--height);
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ > .index {
+ min-height: var(--height);
+ position: relative;
+ border-bottom: solid 0.5px var(--divider);
+
+ > .arrow {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 16px 0;
+ text-align: center;
+ opacity: 0.5;
+ pointer-events: none;
+ }
+ }
+
+ section {
+ > header {
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ padding: 8px;
+ font-size: 12px;
+ }
+
+ > div {
+ padding: $pad;
+
+ > button {
+ position: relative;
+ padding: 0;
+ width: var(--eachSize);
+ height: var(--eachSize);
+ border-radius: 4px;
+
+ &:focus-visible {
+ outline: solid 2px var(--focus);
+ z-index: 1;
+ }
+
+ &: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);
+ }
+
+ > * {
+ font-size: 24px;
+ height: 1.25em;
+ vertical-align: -.25em;
+ pointer-events: none;
+ }
+ }
+ }
+
+ &.result {
+ border-bottom: solid 0.5px var(--divider);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ &.unicode {
+ min-height: 384px;
+ }
+
+ &.custom {
+ min-height: 64px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/emoji-picker.section.vue
new file mode 100644
index 0000000000..2401eca2a5
--- /dev/null
+++ b/packages/client/src/components/emoji-picker.section.vue
@@ -0,0 +1,50 @@
+<template>
+<section>
+ <header class="_acrylic" @click="shown = !shown">
+ <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
+ </header>
+ <div v-if="shown">
+ <button v-for="emoji in emojis"
+ class="_button"
+ @click="chosen(emoji, $event)"
+ :key="emoji"
+ >
+ <MkEmoji :emoji="emoji" :normal="true"/>
+ </button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+
+export default defineComponent({
+ props: {
+ emojis: {
+ required: true,
+ },
+ initialShown: {
+ required: false
+ }
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ getStaticImageUrl,
+ shown: this.initialShown,
+ };
+ },
+
+ methods: {
+ chosen(emoji: any, ev) {
+ this.$parent.chosen(emoji, ev);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue
new file mode 100644
index 0000000000..015e201269
--- /dev/null
+++ b/packages/client/src/components/emoji-picker.vue
@@ -0,0 +1,501 @@
+<template>
+<div class="omfetrab" :class="['w' + width, 'h' + height, { big }]">
+ <input ref="search" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" v-model.trim="q" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()">
+ <div class="emojis" ref="emojis">
+ <section class="result">
+ <div v-if="searchResultCustom.length > 0">
+ <button v-for="emoji in searchResultCustom"
+ class="_button"
+ :title="emoji.name"
+ @click="chosen(emoji, $event)"
+ :key="emoji"
+ tabindex="0"
+ >
+ <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>
+ <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
+ </button>
+ </div>
+ <div v-if="searchResultUnicode.length > 0">
+ <button v-for="emoji in searchResultUnicode"
+ class="_button"
+ :title="emoji.name"
+ @click="chosen(emoji, $event)"
+ :key="emoji.name"
+ tabindex="0"
+ >
+ <MkEmoji :emoji="emoji.char"/>
+ </button>
+ </div>
+ </section>
+
+ <div class="index" v-if="tab === 'index'">
+ <section v-if="showPinned">
+ <div>
+ <button v-for="emoji in pinned"
+ class="_button"
+ @click="chosen(emoji, $event)"
+ tabindex="0"
+ :key="emoji"
+ >
+ <MkEmoji :emoji="emoji" :normal="true"/>
+ </button>
+ </div>
+ </section>
+
+ <section>
+ <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ $ts.recentUsed }}</header>
+ <div>
+ <button v-for="emoji in $store.state.recentlyUsedEmojis"
+ class="_button"
+ @click="chosen(emoji, $event)"
+ :key="emoji"
+ >
+ <MkEmoji :emoji="emoji" :normal="true"/>
+ </button>
+ </div>
+ </section>
+ </div>
+ <div>
+ <header class="_acrylic">{{ $ts.customEmojis }}</header>
+ <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')">{{ category || $ts.other }}</XSection>
+ </div>
+ <div>
+ <header class="_acrylic">{{ $ts.emoji }}</header>
+ <XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)">{{ category }}</XSection>
+ </div>
+ </div>
+ <div class="tabs">
+ <button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="fas fa-asterisk fa-fw"></i></button>
+ <button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="fas fa-laugh fa-fw"></i></button>
+ <button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="fas fa-leaf fa-fw"></i></button>
+ <button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="fas fa-hashtag fa-fw"></i></button>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import { emojilist } from '@/scripts/emojilist';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import Particle from '@/components/particle.vue';
+import * as os from '@/os';
+import { isDeviceTouch } from '@/scripts/is-device-touch';
+import { isMobile } from '@/scripts/is-mobile';
+import { emojiCategories } from '@/instance';
+import XSection from './emoji-picker.section.vue';
+
+export default defineComponent({
+ components: {
+ XSection
+ },
+
+ props: {
+ showPinned: {
+ required: false,
+ default: true
+ },
+ asReactionPicker: {
+ required: false
+ },
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ emojilist: markRaw(emojilist),
+ getStaticImageUrl,
+ pinned: this.$store.reactiveState.reactions,
+ width: this.asReactionPicker ? this.$store.state.reactionPickerWidth : 3,
+ height: this.asReactionPicker ? this.$store.state.reactionPickerHeight : 2,
+ big: this.asReactionPicker ? isDeviceTouch : false,
+ customEmojiCategories: emojiCategories,
+ customEmojis: this.$instance.emojis,
+ q: null,
+ searchResultCustom: [],
+ searchResultUnicode: [],
+ tab: 'index',
+ categories: ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'],
+ };
+ },
+
+ watch: {
+ q() {
+ this.$refs.emojis.scrollTop = 0;
+
+ if (this.q == null || this.q === '') {
+ this.searchResultCustom = [];
+ this.searchResultUnicode = [];
+ return;
+ }
+
+ const q = this.q.replace(/:/g, '');
+
+ const searchCustom = () => {
+ const max = 8;
+ const emojis = this.customEmojis;
+ const matches = new Set();
+
+ const exactMatch = emojis.find(e => e.name === q);
+ if (exactMatch) matches.add(exactMatch);
+
+ if (q.includes(' ')) { // AND検索
+ const keywords = q.split(' ');
+
+ // 名前にキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ // 名前またはエイリアスにキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ } else {
+ for (const emoji of emojis) {
+ if (emoji.name.startsWith(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.aliases.some(alias => alias.startsWith(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.name.includes(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.aliases.some(alias => alias.includes(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ }
+
+ return matches;
+ };
+
+ const searchUnicode = () => {
+ const max = 8;
+ const emojis = this.emojilist;
+ const matches = new Set();
+
+ const exactMatch = emojis.find(e => e.name === q);
+ if (exactMatch) matches.add(exactMatch);
+
+ if (q.includes(' ')) { // AND検索
+ const keywords = q.split(' ');
+
+ // 名前にキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ // 名前またはエイリアスにキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ } else {
+ for (const emoji of emojis) {
+ if (emoji.name.startsWith(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.keywords.some(keyword => keyword.startsWith(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.name.includes(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.keywords.some(keyword => keyword.includes(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ }
+
+ return matches;
+ };
+
+ this.searchResultCustom = Array.from(searchCustom());
+ this.searchResultUnicode = Array.from(searchUnicode());
+ }
+ },
+
+ mounted() {
+ this.focus();
+ },
+
+ methods: {
+ focus() {
+ if (!isMobile && !isDeviceTouch) {
+ this.$refs.search.focus({
+ preventScroll: true
+ });
+ }
+ },
+
+ reset() {
+ this.$refs.emojis.scrollTop = 0;
+ this.q = '';
+ },
+
+ getKey(emoji: any) {
+ return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`);
+ },
+
+ chosen(emoji: any, ev) {
+ if (ev) {
+ const el = ev.currentTarget || ev.target;
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.clientWidth / 2);
+ const y = rect.top + (el.clientHeight / 2);
+ os.popup(Particle, { x, y }, {}, 'end');
+ }
+
+ const key = this.getKey(emoji);
+ this.$emit('chosen', key);
+
+ // 最近使った絵文字更新
+ if (!this.pinned.includes(key)) {
+ let recents = this.$store.state.recentlyUsedEmojis;
+ recents = recents.filter((e: any) => e !== key);
+ recents.unshift(key);
+ this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
+ }
+ },
+
+ paste(event) {
+ const paste = (event.clipboardData || window.clipboardData).getData('text');
+ if (this.done(paste)) {
+ event.preventDefault();
+ }
+ },
+
+ done(query) {
+ if (query == null) query = this.q;
+ if (query == null) return;
+ const q = query.replace(/:/g, '');
+ const exactMatchCustom = this.customEmojis.find(e => e.name === q);
+ if (exactMatchCustom) {
+ this.chosen(exactMatchCustom);
+ return true;
+ }
+ const exactMatchUnicode = this.emojilist.find(e => e.char === q || e.name === q);
+ if (exactMatchUnicode) {
+ this.chosen(exactMatchUnicode);
+ return true;
+ }
+ if (this.searchResultCustom.length > 0) {
+ this.chosen(this.searchResultCustom[0]);
+ return true;
+ }
+ if (this.searchResultUnicode.length > 0) {
+ this.chosen(this.searchResultUnicode[0]);
+ return true;
+ }
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.omfetrab {
+ $pad: 8px;
+ --eachSize: 40px;
+
+ display: flex;
+ flex-direction: column;
+
+ &.big {
+ --eachSize: 44px;
+ }
+
+ &.w1 {
+ width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
+ }
+
+ &.w2 {
+ width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.w3 {
+ width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
+ }
+
+ &.h1 {
+ --height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
+ }
+
+ &.h2 {
+ --height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.h3 {
+ --height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
+ }
+
+ > .search {
+ width: 100%;
+ padding: 12px;
+ box-sizing: border-box;
+ font-size: 1em;
+ outline: none;
+ border: none;
+ background: transparent;
+ color: var(--fg);
+
+ &:not(.filled) {
+ order: 1;
+ z-index: 2;
+ box-shadow: 0px -1px 0 0px var(--divider);
+ }
+ }
+
+ > .tabs {
+ display: flex;
+ display: none;
+
+ > .tab {
+ flex: 1;
+ height: 38px;
+ border-top: solid 0.5px var(--divider);
+
+ &.active {
+ border-top: solid 1px var(--accent);
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .emojis {
+ height: var(--height);
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ > div {
+ &:not(.index) {
+ padding: 4px 0 8px 0;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > header {
+ /*position: sticky;
+ top: 0;
+ left: 0;*/
+ height: 32px;
+ line-height: 32px;
+ z-index: 2;
+ padding: 0 8px;
+ font-size: 12px;
+ }
+ }
+
+ ::v-deep(section) {
+ > header {
+ position: sticky;
+ top: 0;
+ left: 0;
+ height: 32px;
+ line-height: 32px;
+ z-index: 1;
+ padding: 0 8px;
+ font-size: 12px;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--accent);
+ }
+ }
+
+ > div {
+ position: relative;
+ padding: $pad;
+
+ > button {
+ position: relative;
+ padding: 0;
+ width: var(--eachSize);
+ height: var(--eachSize);
+ border-radius: 4px;
+
+ &:focus-visible {
+ outline: solid 2px var(--focus);
+ z-index: 1;
+ }
+
+ &: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);
+ }
+
+ > * {
+ font-size: 24px;
+ height: 1.25em;
+ vertical-align: -.25em;
+ pointer-events: none;
+ }
+ }
+ }
+
+ &.result {
+ border-bottom: solid 0.5px var(--divider);
+
+ &:empty {
+ display: none;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/featured-photos.vue b/packages/client/src/components/featured-photos.vue
new file mode 100644
index 0000000000..276344dfb4
--- /dev/null
+++ b/packages/client/src/components/featured-photos.vue
@@ -0,0 +1,32 @@
+<template>
+<div class="xfbouadm" v-if="meta" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ },
+
+ data() {
+ return {
+ meta: null,
+ };
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.xfbouadm {
+ background-position: center;
+ background-size: cover;
+}
+</style>
diff --git a/packages/client/src/components/file-type-icon.vue b/packages/client/src/components/file-type-icon.vue
new file mode 100644
index 0000000000..be1af5e501
--- /dev/null
+++ b/packages/client/src/components/file-type-icon.vue
@@ -0,0 +1,28 @@
+<template>
+<span class="mk-file-type-icon">
+ <template v-if="kind == 'image'"><i class="fas fa-file-image"></i></template>
+</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ type: {
+ type: String,
+ required: true,
+ }
+ },
+ data() {
+ return {
+ };
+ },
+ computed: {
+ kind(): string {
+ return this.type.split('/')[0];
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/follow-button.vue
new file mode 100644
index 0000000000..a96899027f
--- /dev/null
+++ b/packages/client/src/components/follow-button.vue
@@ -0,0 +1,210 @@
+<template>
+<button class="kpoogebi _button"
+ :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
+ @click="onClick"
+ :disabled="wait"
+>
+ <template v-if="!wait">
+ <template v-if="hasPendingFollowRequestFromYou && user.isLocked">
+ <span v-if="full">{{ $ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i>
+ </template>
+ <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 -->
+ <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i>
+ </template>
+ <template v-else-if="isFollowing">
+ <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
+ </template>
+ <template v-else-if="!isFollowing && user.isLocked">
+ <span v-if="full">{{ $ts.followRequest }}</span><i class="fas fa-plus"></i>
+ </template>
+ <template v-else-if="!isFollowing && !user.isLocked">
+ <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
+ </template>
+ </template>
+ <template v-else>
+ <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
+ </template>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ large: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ isFollowing: this.user.isFollowing,
+ hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou,
+ wait: false,
+ connection: null,
+ };
+ },
+
+ created() {
+ // 渡されたユーザー情報が不完全な場合
+ if (this.user.isFollowing == null) {
+ os.api('users/show', {
+ userId: this.user.id
+ }).then(u => {
+ this.isFollowing = u.isFollowing;
+ this.hasPendingFollowRequestFromYou = u.hasPendingFollowRequestFromYou;
+ });
+ }
+ },
+
+ mounted() {
+ this.connection = markRaw(os.stream.useChannel('main'));
+
+ this.connection.on('follow', this.onFollowChange);
+ this.connection.on('unfollow', this.onFollowChange);
+ },
+
+ beforeUnmount() {
+ 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 os.dialog({
+ type: 'warning',
+ text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }),
+ showCancelButton: true
+ });
+
+ if (canceled) return;
+
+ await os.api('following/delete', {
+ userId: this.user.id
+ });
+ } else {
+ if (this.hasPendingFollowRequestFromYou) {
+ await os.api('following/requests/cancel', {
+ userId: this.user.id
+ });
+ } else if (this.user.isLocked) {
+ await os.api('following/create', {
+ userId: this.user.id
+ });
+ this.hasPendingFollowRequestFromYou = true;
+ } else {
+ await os.api('following/create', {
+ userId: this.user.id
+ });
+ this.hasPendingFollowRequestFromYou = true;
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ this.wait = false;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kpoogebi {
+ position: relative;
+ display: inline-block;
+ font-weight: bold;
+ color: var(--accent);
+ background: transparent;
+ border: solid 1px var(--accent);
+ padding: 0;
+ height: 31px;
+ font-size: 16px;
+ border-radius: 32px;
+ background: #fff;
+
+ &.full {
+ padding: 0 8px 0 12px;
+ font-size: 14px;
+ }
+
+ &.large {
+ font-size: 16px;
+ height: 38px;
+ padding: 0 12px 0 16px;
+ }
+
+ &:not(.full) {
+ width: 31px;
+ }
+
+ &:focus-visible {
+ &:after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ bottom: -5px;
+ left: -5px;
+ border: 2px solid var(--focus);
+ border-radius: 32px;
+ }
+ }
+
+ &: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;
+ }
+
+ > span {
+ margin-right: 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/forgot-password.vue
new file mode 100644
index 0000000000..a42ea5864a
--- /dev/null
+++ b/packages/client/src/components/forgot-password.vue
@@ -0,0 +1,84 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ :height="400"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.forgotPassword }}</template>
+
+ <form class="bafeceda" @submit.prevent="onSubmit" v-if="$instance.enableEmail">
+ <div class="main _formRoot">
+ <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required>
+ <template #label>{{ $ts.username }}</template>
+ <template #prefix>@</template>
+ </MkInput>
+
+ <MkInput class="_formBlock" v-model="email" type="email" spellcheck="false" required>
+ <template #label>{{ $ts.emailAddress }}</template>
+ <template #caption>{{ $ts._forgotPassword.enterEmail }}</template>
+ </MkInput>
+
+ <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton>
+ </div>
+ <div class="sub">
+ <MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA>
+ </div>
+ </form>
+ <div v-else>
+ {{ $ts._forgotPassword.contactAdmin }}
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkButton,
+ MkInput,
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ username: '',
+ email: '',
+ processing: false,
+ };
+ },
+
+ methods: {
+ async onSubmit() {
+ this.processing = true;
+ await os.apiWithDialog('request-reset-password', {
+ username: this.username,
+ email: this.email,
+ });
+
+ this.$emit('done');
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.bafeceda {
+ > .main {
+ padding: 24px;
+ }
+
+ > .sub {
+ border-top: solid 0.5px var(--divider);
+ padding: 24px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/form-dialog.vue
new file mode 100644
index 0000000000..172e6a5138
--- /dev/null
+++ b/packages/client/src/components/form-dialog.vue
@@ -0,0 +1,125 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="450"
+ :can-close="false"
+ :with-ok-button="true"
+ :ok-button-disabled="false"
+ @click="cancel()"
+ @ok="ok()"
+ @close="cancel()"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ {{ title }}
+ </template>
+ <FormBase class="xkpnjxcv">
+ <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
+ <FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
+ <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormInput>
+ <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text">
+ <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormInput>
+ <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]">
+ <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormTextarea>
+ <FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]">
+ <span v-text="form[item].label || item"></span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormSwitch>
+ <FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]">
+ <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+ <option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option>
+ </FormSelect>
+ <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]">
+ <template #desc><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+ <option v-for="item in form[item].options" :value="item.value" :key="item.value">{{ item.label }}</option>
+ </FormRadios>
+ <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step">
+ <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormRange>
+ <FormButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
+ <span v-text="form[item].content || item"></span>
+ </FormButton>
+ </template>
+ </FormBase>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import FormBase from './debobigego/base.vue';
+import FormInput from './debobigego/input.vue';
+import FormTextarea from './debobigego/textarea.vue';
+import FormSwitch from './debobigego/switch.vue';
+import FormSelect from './debobigego/select.vue';
+import FormRange from './debobigego/range.vue';
+import FormButton from './debobigego/button.vue';
+import FormRadios from './debobigego/radios.vue';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ FormBase,
+ FormInput,
+ FormTextarea,
+ FormSwitch,
+ FormSelect,
+ FormRange,
+ FormButton,
+ FormRadios,
+ },
+
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ form: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ emits: ['done'],
+
+ data() {
+ return {
+ values: {}
+ };
+ },
+
+ created() {
+ for (const item in this.form) {
+ this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null;
+ }
+ },
+
+ methods: {
+ ok() {
+ this.$emit('done', {
+ result: this.values
+ });
+ this.$refs.dialog.close();
+ },
+
+ cancel() {
+ this.$emit('done', {
+ canceled: true
+ });
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xkpnjxcv {
+
+}
+</style>
diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue
new file mode 100644
index 0000000000..f2c1ead00c
--- /dev/null
+++ b/packages/client/src/components/form/input.vue
@@ -0,0 +1,315 @@
+<template>
+<div class="matxzzsk">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ inline, disabled, focused }">
+ <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+ <input ref="inputEl"
+ :type="type"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ :list="id"
+ >
+ <datalist :id="id" v-if="datalist">
+ <option v-for="data in datalist" :value="data"/>
+ </datalist>
+ <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import { debounce } from 'throttle-debounce';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ modelValue: {
+ required: true
+ },
+ 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
+ },
+ step: {
+ required: false
+ },
+ datalist: {
+ type: Array,
+ required: false,
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ debounce: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+
+ setup(props, context) {
+ const { modelValue, type, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const id = Math.random().toString(); // TODO: uuid?
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+ const prefixEl = ref(null);
+ const suffixEl = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ context.emit('enter');
+ }
+ };
+
+ const updated = () => {
+ changed.value = false;
+ if (type?.value === 'number') {
+ context.emit('update:modelValue', parseFloat(v.value));
+ } else {
+ context.emit('update:modelValue', v.value);
+ }
+ };
+
+ const debouncedUpdated = debounce(1000, updated);
+
+ watch(modelValue, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ if (props.debounce) {
+ debouncedUpdated();
+ } else {
+ updated();
+ }
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+
+ // このコンポーネントが作成された時、非表示状態である場合がある
+ // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+ const clock = setInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+ }, 100);
+
+ onUnmounted(() => {
+ clearInterval(clock);
+ });
+ });
+ });
+
+ return {
+ id,
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ prefixEl,
+ suffixEl,
+ focus,
+ onInput,
+ onKeydown,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.matxzzsk {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ $height: 42px;
+ position: relative;
+
+ > input {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: $height;
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 0.5px var(--inputBorder);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
+
+ &:hover {
+ border-color: var(--inputBorderHover);
+ }
+ }
+
+ > .prefix,
+ > .suffix {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 12px;
+ font-size: 1em;
+ height: $height;
+ 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: 6px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 6px;
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.focused {
+ > input {
+ border-color: var(--accent);
+ //box-shadow: 0 0 0 4px var(--focus);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue
new file mode 100644
index 0000000000..0f31d8fa0a
--- /dev/null
+++ b/packages/client/src/components/form/radio.vue
@@ -0,0 +1,122 @@
+<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 { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ required: false
+ },
+ value: {
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ checked(): boolean {
+ return this.modelValue === this.value;
+ }
+ },
+ methods: {
+ toggle() {
+ if (this.disabled) return;
+ this.$emit('update:modelValue', this.value);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.novjtctn {
+ position: relative;
+ display: inline-block;
+ margin: 8px 20px 0 0;
+ text-align: left;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ > * {
+ user-select: none;
+ }
+
+ &.disabled {
+ opacity: 0.6;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ &.checked {
+ > .button {
+ border-color: var(--accent);
+
+ &:after {
+ background-color: var(--accent);
+ 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(--inputBorder);
+ 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/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue
new file mode 100644
index 0000000000..998a738202
--- /dev/null
+++ b/packages/client/src/components/form/radios.vue
@@ -0,0 +1,54 @@
+<script lang="ts">
+import { defineComponent, h } from 'vue';
+import MkRadio from './radio.vue';
+
+export default defineComponent({
+ components: {
+ MkRadio
+ },
+ props: {
+ modelValue: {
+ required: false
+ },
+ },
+ data() {
+ return {
+ value: this.modelValue,
+ }
+ },
+ watch: {
+ value() {
+ this.$emit('update:modelValue', this.value);
+ }
+ },
+ render() {
+ let options = this.$slots.default();
+
+ // なぜかFragmentになることがあるため
+ if (options.length === 1 && options[0].props == null) options = options[0].children;
+
+ return h('div', {
+ class: 'novjtcto'
+ }, [
+ ...options.map(option => h(MkRadio, {
+ key: option.key,
+ value: option.props.value,
+ modelValue: this.value,
+ 'onUpdate:modelValue': value => this.value = value,
+ }, option.children))
+ ]);
+ }
+});
+</script>
+
+<style lang="scss">
+.novjtcto {
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue
new file mode 100644
index 0000000000..4cfe66a8fc
--- /dev/null
+++ b/packages/client/src/components/form/range.vue
@@ -0,0 +1,139 @@
+<template>
+<div class="timctyfi" :class="{ focused, disabled }">
+ <div class="icon"><slot name="icon"></slot></div>
+ <span class="label"><slot name="label"></slot></span>
+ <input
+ type="range"
+ ref="input"
+ v-model="v"
+ :disabled="disabled"
+ :min="min"
+ :max="max"
+ :step="step"
+ :autofocus="autofocus"
+ @focus="focused = true"
+ @blur="focused = false"
+ @input="$emit('update:value', $event.target.value)"
+ />
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ value: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ min: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ max: {
+ type: Number,
+ required: false,
+ default: 100
+ },
+ step: {
+ type: Number,
+ required: false,
+ default: 1
+ },
+ autofocus: {
+ type: Boolean,
+ required: false
+ }
+ },
+ data() {
+ return {
+ v: this.value,
+ focused: false
+ };
+ },
+ watch: {
+ value(v) {
+ this.v = parseFloat(v);
+ }
+ },
+ mounted() {
+ if (this.autofocus) {
+ this.$nextTick(() => {
+ this.$refs.input.focus();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.timctyfi {
+ position: relative;
+ margin: 8px;
+
+ > .icon {
+ display: inline-block;
+ width: 24px;
+ text-align: center;
+ }
+
+ > .title {
+ pointer-events: none;
+ font-size: 16px;
+ color: var(--inputLabel);
+ overflow: hidden;
+ }
+
+ > input {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background: var(--X10);
+ height: 7px;
+ margin: 0 8px;
+ outline: 0;
+ border: 0;
+ border-radius: 7px;
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ box-sizing: content-box;
+ }
+
+ &::-moz-range-thumb {
+ -moz-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/section.vue b/packages/client/src/components/form/section.vue
new file mode 100644
index 0000000000..8eac40a0db
--- /dev/null
+++ b/packages/client/src/components/form/section.vue
@@ -0,0 +1,31 @@
+<template>
+<div class="vrtktovh" v-size="{ max: [500] }" v-sticky-container>
+ <div class="label"><slot name="label"></slot></div>
+ <div class="main">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+
+});
+</script>
+
+<style lang="scss" scoped>
+.vrtktovh {
+ border-top: solid 0.5px var(--divider);
+
+ > .label {
+ font-weight: bold;
+ padding: 24px 0 16px 0;
+ }
+
+ > .main {
+ margin-bottom: 32px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue
new file mode 100644
index 0000000000..f7eb5cd14d
--- /dev/null
+++ b/packages/client/src/components/form/select.vue
@@ -0,0 +1,312 @@
+<template>
+<div class="vblkjoeq">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick" ref="container">
+ <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+ <select class="select" ref="inputEl"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ @focus="focused = true"
+ @blur="focused = false"
+ @input="onInput"
+ >
+ <slot></slot>
+ </select>
+ <div class="suffix" ref="suffixEl"><i class="fas fa-chevron-down"></i></div>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ modelValue: {
+ required: true
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ readonly: {
+ type: Boolean,
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ required: false
+ },
+ placeholder: {
+ type: String,
+ required: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['change', 'update:modelValue'],
+
+ setup(props, context) {
+ const { modelValue, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+ const prefixEl = ref(null);
+ const suffixEl = ref(null);
+ const container = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+
+ const updated = () => {
+ changed.value = false;
+ context.emit('update:modelValue', v.value);
+ };
+
+ watch(modelValue, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ updated();
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+
+ // このコンポーネントが作成された時、非表示状態である場合がある
+ // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+ const clock = setInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+ }, 100);
+
+ onUnmounted(() => {
+ clearInterval(clock);
+ });
+ });
+ });
+
+ const onClick = (ev: MouseEvent) => {
+ focused.value = true;
+
+ const menu = [];
+ let options = context.slots.default();
+
+ const pushOption = (option: VNode) => {
+ menu.push({
+ text: option.children,
+ active: v.value === option.props.value,
+ action: () => {
+ v.value = option.props.value;
+ },
+ });
+ };
+
+ const scanOptions = (options: VNode[]) => {
+ for (const vnode of options) {
+ if (vnode.type === 'optgroup') {
+ const optgroup = vnode;
+ menu.push({
+ type: 'label',
+ text: optgroup.props.label,
+ });
+ scanOptions(optgroup.children);
+ } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
+ const fragment = vnode;
+ scanOptions(fragment.children);
+ } else {
+ const option = vnode;
+ pushOption(option);
+ }
+ }
+ };
+
+ scanOptions(options);
+
+ os.popupMenu(menu, container.value, {
+ width: container.value.offsetWidth,
+ }).then(() => {
+ focused.value = false;
+ });
+ };
+
+ return {
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ prefixEl,
+ suffixEl,
+ container,
+ focus,
+ onInput,
+ onClick,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.vblkjoeq {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ $height: 42px;
+ position: relative;
+ cursor: pointer;
+
+ &:hover {
+ > .select {
+ border-color: var(--inputBorderHover);
+ }
+ }
+
+ > .select {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: $height;
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 1px var(--inputBorder);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ cursor: pointer;
+ transition: border-color 0.1s ease-out;
+ pointer-events: none;
+ }
+
+ > .prefix,
+ > .suffix {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 12px;
+ font-size: 1em;
+ height: $height;
+ 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: 6px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 6px;
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.focused {
+ > select {
+ border-color: var(--accent);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/slot.vue b/packages/client/src/components/form/slot.vue
new file mode 100644
index 0000000000..8580c1307d
--- /dev/null
+++ b/packages/client/src/components/form/slot.vue
@@ -0,0 +1,50 @@
+<template>
+<div class="adhpbeou">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="content">
+ <slot></slot>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+
+});
+</script>
+
+<style lang="scss" scoped>
+.adhpbeou {
+ margin: 1.5em 0;
+
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .content {
+ position: relative;
+ background: var(--panel);
+ border: solid 0.5px var(--inputBorder);
+ border-radius: 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue
new file mode 100644
index 0000000000..85f8b7c870
--- /dev/null
+++ b/packages/client/src/components/form/switch.vue
@@ -0,0 +1,150 @@
+<template>
+<div
+ class="ziffeoms"
+ :class="{ disabled, checked }"
+ role="switch"
+ :aria-checked="checked"
+ :aria-disabled="disabled"
+ @click.prevent="toggle"
+>
+ <input
+ type="checkbox"
+ ref="input"
+ :disabled="disabled"
+ @keydown.enter="toggle"
+ >
+ <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff">
+ <span class="handle"></span>
+ </span>
+ <span class="label">
+ <span><slot></slot></span>
+ <p><slot name="caption"></slot></p>
+ </span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ type: Boolean,
+ default: false
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ checked(): boolean {
+ return this.modelValue;
+ }
+ },
+ methods: {
+ toggle() {
+ if (this.disabled) return;
+ this.$emit('update:modelValue', !this.checked);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ziffeoms {
+ position: relative;
+ display: flex;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ > * {
+ user-select: none;
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ margin: 0;
+ width: 36px;
+ height: 26px;
+ background: var(--switchBg);
+ outline: none;
+ border-radius: 999px;
+ transition: inherit;
+
+ > .handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 5px;
+ margin: auto 0;
+ border-radius: 100%;
+ transition: background-color 0.3s, transform 0.3s;
+ width: 16px;
+ height: 16px;
+ background-color: #fff;
+ }
+ }
+
+ > .label {
+ margin-left: 16px;
+ margin-top: 2px;
+ display: block;
+ cursor: pointer;
+ transition: inherit;
+ color: var(--fg);
+
+ > span {
+ display: block;
+ line-height: 20px;
+ transition: inherit;
+ }
+
+ > p {
+ margin: 0;
+ color: var(--fgTransparentWeak);
+ font-size: 90%;
+ }
+ }
+
+ &:hover {
+ > .button {
+ background-color: var(--accentedBg);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &.checked {
+ > .button {
+ background-color: var(--accent);
+ border-color: var(--accent);
+
+ > .handle {
+ transform: translateX(10px);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/textarea.vue b/packages/client/src/components/form/textarea.vue
new file mode 100644
index 0000000000..fdb24f1e2b
--- /dev/null
+++ b/packages/client/src/components/form/textarea.vue
@@ -0,0 +1,252 @@
+<template>
+<div class="adhpbeos">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ disabled, focused, tall, pre }">
+ <textarea ref="inputEl"
+ :class="{ code, _monospace: code }"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ ></textarea>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import { debounce } from 'throttle-debounce';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ modelValue: {
+ required: true
+ },
+ 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
+ },
+ code: {
+ type: Boolean,
+ required: false
+ },
+ tall: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ pre: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ debounce: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+
+ setup(props, context) {
+ const { modelValue, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ context.emit('enter');
+ }
+ };
+
+ const updated = () => {
+ changed.value = false;
+ context.emit('update:modelValue', v.value);
+ };
+
+ const debouncedUpdated = debounce(1000, updated);
+
+ watch(modelValue, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ if (props.debounce) {
+ debouncedUpdated();
+ } else {
+ updated();
+ }
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+ });
+ });
+
+ return {
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ focus,
+ onInput,
+ onKeydown,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.adhpbeos {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ position: relative;
+
+ > textarea {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 0.5px var(--inputBorder);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
+
+ &:hover {
+ border-color: var(--inputBorderHover);
+ }
+ }
+
+ &.focused {
+ > textarea {
+ border-color: var(--accent);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ &.tall {
+ > textarea {
+ min-height: 200px;
+ }
+ }
+
+ &.pre {
+ > textarea {
+ white-space: pre;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/formula-core.vue b/packages/client/src/components/formula-core.vue
new file mode 100644
index 0000000000..cf8dee872b
--- /dev/null
+++ b/packages/client/src/components/formula-core.vue
@@ -0,0 +1,34 @@
+
+<template>
+<div v-if="block" v-html="compiledFormula"></div>
+<span v-else v-html="compiledFormula"></span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as katex from 'katex';import * as os from '@/os';
+
+export default defineComponent({
+ 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/packages/client/src/components/formula.vue b/packages/client/src/components/formula.vue
new file mode 100644
index 0000000000..fbb40bace7
--- /dev/null
+++ b/packages/client/src/components/formula.vue
@@ -0,0 +1,23 @@
+<template>
+<XFormula :formula="formula" :block="block" />
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XFormula: defineAsyncComponent(() => import('./formula-core.vue'))
+ },
+ props: {
+ formula: {
+ type: String,
+ required: true
+ },
+ block: {
+ type: Boolean,
+ required: true
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/gallery-post-preview.vue b/packages/client/src/components/gallery-post-preview.vue
new file mode 100644
index 0000000000..8245902976
--- /dev/null
+++ b/packages/client/src/components/gallery-post-preview.vue
@@ -0,0 +1,126 @@
+<template>
+<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
+ <div class="thumbnail">
+ <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
+ </div>
+ <article>
+ <header>
+ <MkAvatar :user="post.user" class="avatar"/>
+ </header>
+ <footer>
+ <span class="title">{{ post.title }}</span>
+ </footer>
+ </article>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { userName } from '@/filters/user';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ ImgWithBlurhash
+ },
+ props: {
+ post: {
+ type: Object,
+ required: true
+ },
+ },
+ methods: {
+ userName
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ttasepnz {
+ display: block;
+ position: relative;
+ height: 200px;
+
+ &:hover {
+ text-decoration: none;
+ color: var(--accent);
+
+ > .thumbnail {
+ transform: scale(1.1);
+ }
+
+ > article {
+ > footer {
+ &:before {
+ opacity: 1;
+ }
+ }
+ }
+ }
+
+ > .thumbnail {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ transition: all 0.5s ease;
+
+ > .img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ > article {
+ position: absolute;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+
+ > header {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ padding: 12px;
+ box-sizing: border-box;
+ display: flex;
+
+ > .avatar {
+ margin-left: auto;
+ width: 32px;
+ height: 32px;
+ }
+ }
+
+ > footer {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ padding: 16px;
+ box-sizing: border-box;
+ color: #fff;
+ text-shadow: 0 0 8px #000;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
+ opacity: 0;
+ transition: opacity 0.5s ease;
+ }
+
+ > .title {
+ font-weight: bold;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue
new file mode 100644
index 0000000000..5db61203c6
--- /dev/null
+++ b/packages/client/src/components/global/a.vue
@@ -0,0 +1,138 @@
+<template>
+<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
+ <slot></slot>
+</a>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { router } from '@/router';
+import { url } from '@/config';
+import { popout } from '@/scripts/popout';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+ inject: {
+ navHook: {
+ default: null
+ },
+ sideViewHook: {
+ default: null
+ }
+ },
+
+ props: {
+ to: {
+ type: String,
+ required: true,
+ },
+ activeClass: {
+ type: String,
+ required: false,
+ },
+ behavior: {
+ type: String,
+ required: false,
+ },
+ },
+
+ computed: {
+ active() {
+ if (this.activeClass == null) return false;
+ const resolved = router.resolve(this.to);
+ if (resolved.path == this.$route.path) return true;
+ if (resolved.name == null) return false;
+ if (this.$route.name == null) return false;
+ return resolved.name == this.$route.name;
+ }
+ },
+
+ methods: {
+ onContextmenu(e) {
+ if (window.getSelection().toString() !== '') return;
+ os.contextMenu([{
+ type: 'label',
+ text: this.to,
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(this.to);
+ }
+ }, this.sideViewHook ? {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.sideViewHook(this.to);
+ }
+ } : undefined, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: () => {
+ this.$router.push(this.to);
+ }
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.to, '_blank');
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(`${url}${this.to}`);
+ }
+ }], e);
+ },
+
+ window() {
+ os.pageWindow(this.to);
+ },
+
+ modalWindow() {
+ os.modalPageWindow(this.to);
+ },
+
+ popout() {
+ popout(this.to);
+ },
+
+ nav() {
+ if (this.behavior === 'browser') {
+ location.href = this.to;
+ return;
+ }
+
+ if (this.to.startsWith('/my/messaging')) {
+ if (ColdDeviceStorage.get('chatOpenBehavior') === 'window') return this.window();
+ if (ColdDeviceStorage.get('chatOpenBehavior') === 'popout') return this.popout();
+ }
+
+ if (this.behavior) {
+ if (this.behavior === 'window') {
+ return this.window();
+ } else if (this.behavior === 'modalWindow') {
+ return this.modalWindow();
+ }
+ }
+
+ if (this.navHook) {
+ this.navHook(this.to);
+ } else {
+ if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') {
+ return this.sideViewHook(this.to);
+ }
+
+ if (this.$router.currentRoute.value.path === this.to) {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ } else {
+ this.$router.push(this.to);
+ }
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/global/acct.vue b/packages/client/src/components/global/acct.vue
new file mode 100644
index 0000000000..b0c41c99c0
--- /dev/null
+++ b/packages/client/src/components/global/acct.vue
@@ -0,0 +1,38 @@
+<template>
+<span class="mk-acct">
+ <span class="name">@{{ user.username }}</span>
+ <span class="host" v-if="user.host || detail || $store.state.showFullAcct">@{{ user.host || host }}</span>
+</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import { host } from '@/config';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ detail: {
+ type: Boolean,
+ default: false
+ },
+ },
+ data() {
+ return {
+ host: toUnicode(host),
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-acct {
+ > .host {
+ opacity: 0.5;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/ad.vue b/packages/client/src/components/global/ad.vue
new file mode 100644
index 0000000000..71cb16740c
--- /dev/null
+++ b/packages/client/src/components/global/ad.vue
@@ -0,0 +1,200 @@
+<template>
+<div class="qiivuoyo" v-if="ad">
+ <div class="main" :class="ad.place" v-if="!showMenu">
+ <a :href="ad.url" target="_blank">
+ <img :src="ad.imageUrl">
+ <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button>
+ </a>
+ </div>
+ <div class="menu" v-else>
+ <div class="body">
+ <div>Ads by {{ host }}</div>
+ <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
+ <MkButton v-if="ad.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
+ <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
+ </div>
+ </div>
+</div>
+<div v-else></div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { Instance, instance } from '@/instance';
+import { host } from '@/config';
+import MkButton from '@/components/ui/button.vue';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ prefer: {
+ type: Array,
+ required: true
+ },
+ specify: {
+ type: Object,
+ required: false
+ },
+ },
+
+ setup(props) {
+ const showMenu = ref(false);
+ const toggleMenu = () => {
+ showMenu.value = !showMenu.value;
+ };
+
+ const choseAd = (): Instance['ads'][number] | null => {
+ if (props.specify) {
+ return props.specify as Instance['ads'][number];
+ }
+
+ const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
+ ...ad,
+ ratio: 0
+ } : ad);
+
+ let ads = allAds.filter(ad => props.prefer.includes(ad.place));
+
+ if (ads.length === 0) {
+ ads = allAds.filter(ad => ad.place === 'square');
+ }
+
+ const lowPriorityAds = ads.filter(ad => ad.ratio === 0);
+ ads = ads.filter(ad => ad.ratio !== 0);
+
+ if (ads.length === 0) {
+ if (lowPriorityAds.length !== 0) {
+ return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)];
+ } else {
+ return null;
+ }
+ }
+
+ const totalFactor = ads.reduce((a, b) => a + b.ratio, 0);
+ const r = Math.random() * totalFactor;
+
+ let stackedFactor = 0;
+ for (const ad of ads) {
+ if (r >= stackedFactor && r <= stackedFactor + ad.ratio) {
+ return ad;
+ } else {
+ stackedFactor += ad.ratio;
+ }
+ }
+
+ return null;
+ };
+
+ const chosen = ref(choseAd());
+
+ const reduceFrequency = () => {
+ if (chosen.value == null) return;
+ if (defaultStore.state.mutedAds.includes(chosen.value.id)) return;
+ defaultStore.push('mutedAds', chosen.value.id);
+ os.success();
+ chosen.value = choseAd();
+ showMenu.value = false;
+ };
+
+ return {
+ ad: chosen,
+ showMenu,
+ toggleMenu,
+ host,
+ reduceFrequency,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qiivuoyo {
+ background-size: auto auto;
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px );
+
+ > .main {
+ text-align: center;
+
+ > a {
+ display: inline-block;
+ position: relative;
+ vertical-align: bottom;
+
+ &:hover {
+ > img {
+ filter: contrast(120%);
+ }
+ }
+
+ > img {
+ display: block;
+ object-fit: contain;
+ margin: auto;
+ }
+
+ > .menu {
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: var(--panel);
+ }
+ }
+
+ &.square {
+ > a ,
+ > a > img {
+ max-width: min(300px, 100%);
+ max-height: 300px;
+ }
+ }
+
+ &.horizontal {
+ padding: 8px;
+
+ > a ,
+ > a > img {
+ max-width: min(600px, 100%);
+ max-height: 80px;
+ }
+ }
+
+ &.horizontal-big {
+ padding: 8px;
+
+ > a ,
+ > a > img {
+ max-width: min(600px, 100%);
+ max-height: 250px;
+ }
+ }
+
+ &.vertical {
+ > a ,
+ > a > img {
+ max-width: min(100px, 100%);
+ }
+ }
+ }
+
+ > .menu {
+ padding: 8px;
+ text-align: center;
+
+ > .body {
+ padding: 8px;
+ margin: 0 auto;
+ max-width: 400px;
+ border: solid 1px var(--divider);
+
+ > .button {
+ margin: 8px auto;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/avatar.vue
new file mode 100644
index 0000000000..e509e893da
--- /dev/null
+++ b/packages/client/src/components/global/avatar.vue
@@ -0,0 +1,163 @@
+<template>
+<span class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
+ <img class="inner" :src="url" decoding="async"/>
+ <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</span>
+<MkA class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
+ <img class="inner" :src="url" decoding="async"/>
+ <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+import { acct, userPage } from '@/filters/user';
+import MkUserOnlineIndicator from '@/components/user-online-indicator.vue';
+
+export default defineComponent({
+ components: {
+ MkUserOnlineIndicator
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ target: {
+ required: false,
+ default: null
+ },
+ disableLink: {
+ required: false,
+ default: false
+ },
+ disablePreview: {
+ required: false,
+ default: false
+ },
+ showIndicator: {
+ required: false,
+ default: false
+ }
+ },
+ emits: ['click'],
+ computed: {
+ cat(): boolean {
+ return this.user.isCat;
+ },
+ url(): string {
+ return this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(this.user.avatarUrl)
+ : this.user.avatarUrl;
+ },
+ },
+ watch: {
+ 'user.avatarBlurhash'() {
+ if (this.$el == null) return;
+ this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
+ }
+ },
+ mounted() {
+ this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
+ },
+ methods: {
+ onClick(e) {
+ this.$emit('click', e);
+ },
+ acct,
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes earwiggleleft {
+ from { transform: rotate(37.6deg) skew(30deg); }
+ 25% { transform: rotate(10deg) skew(30deg); }
+ 50% { transform: rotate(20deg) skew(30deg); }
+ 75% { transform: rotate(0deg) skew(30deg); }
+ to { transform: rotate(37.6deg) skew(30deg); }
+}
+
+@keyframes earwiggleright {
+ from { transform: rotate(-37.6deg) skew(-30deg); }
+ 30% { transform: rotate(-10deg) skew(-30deg); }
+ 55% { transform: rotate(-20deg) skew(-30deg); }
+ 75% { transform: rotate(0deg) skew(-30deg); }
+ to { transform: rotate(-37.6deg) skew(-30deg); }
+}
+
+.eiwwqkts {
+ position: relative;
+ display: inline-block;
+ vertical-align: bottom;
+ flex-shrink: 0;
+ border-radius: 100%;
+ line-height: 16px;
+
+ > .inner {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ top: 0;
+ border-radius: 100%;
+ z-index: 1;
+ overflow: hidden;
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+ }
+
+ > .indicator {
+ position: absolute;
+ z-index: 1;
+ bottom: 0;
+ left: 0;
+ width: 20%;
+ height: 20%;
+ }
+
+ &.square {
+ border-radius: 20%;
+
+ > .inner {
+ border-radius: 20%;
+ }
+ }
+
+ &.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);
+ }
+
+ &:hover {
+ &:before {
+ animation: earwiggleleft 1s infinite;
+ }
+
+ &:after {
+ animation: earwiggleright 1s infinite;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/ellipsis.vue b/packages/client/src/components/global/ellipsis.vue
new file mode 100644
index 0000000000..0a46f486d6
--- /dev/null
+++ b/packages/client/src/components/global/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/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/emoji.vue
new file mode 100644
index 0000000000..67a3dea2c5
--- /dev/null
+++ b/packages/client/src/components/global/emoji.vue
@@ -0,0 +1,125 @@
+<template>
+<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/>
+<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/>
+<span v-else-if="char && useOsNativeEmojis">{{ char }}</span>
+<span v-else>{{ emoji }}</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { twemojiSvgBase } from '@/scripts/twemoji-base';
+
+export default defineComponent({
+ props: {
+ emoji: {
+ type: String,
+ required: true
+ },
+ normal: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noStyle: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ customEmojis: {
+ required: false
+ },
+ isReaction: {
+ type: Boolean,
+ default: false
+ },
+ },
+
+ data() {
+ return {
+ url: null,
+ char: null,
+ customEmoji: null
+ }
+ },
+
+ computed: {
+ isCustom(): boolean {
+ return this.emoji.startsWith(':');
+ },
+
+ alt(): string {
+ return this.customEmoji ? `:${this.customEmoji.name}:` : this.char;
+ },
+
+ useOsNativeEmojis(): boolean {
+ return this.$store.state.useOsNativeEmojis && !this.isReaction;
+ },
+
+ ce() {
+ return this.customEmojis || this.$instance?.emojis || [];
+ }
+ },
+
+ watch: {
+ ce: {
+ handler() {
+ if (this.isCustom) {
+ const customEmoji = this.ce.find(x => x.name === this.emoji.substr(1, this.emoji.length - 2));
+ if (customEmoji) {
+ this.customEmoji = customEmoji;
+ this.url = this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(customEmoji.url)
+ : customEmoji.url;
+ }
+ }
+ },
+ immediate: true
+ },
+ },
+
+ created() {
+ if (!this.isCustom) {
+ 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/packages/client/src/components/global/error.vue b/packages/client/src/components/global/error.vue
new file mode 100644
index 0000000000..8ce5d16ac6
--- /dev/null
+++ b/packages/client/src/components/global/error.vue
@@ -0,0 +1,46 @@
+<template>
+<transition :name="$store.state.animation ? 'zoom' : ''" appear>
+ <div class="mjndxjcg">
+ <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+ <p><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</p>
+ <MkButton @click="() => $emit('retry')" class="button">{{ $ts.retry }}</MkButton>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+ data() {
+ return {
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mjndxjcg {
+ padding: 32px;
+ text-align: center;
+
+ > p {
+ margin: 0 0 8px 0;
+ }
+
+ > .button {
+ margin: 0 auto;
+ }
+
+ > img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue
new file mode 100644
index 0000000000..7d5e426f2b
--- /dev/null
+++ b/packages/client/src/components/global/header.vue
@@ -0,0 +1,360 @@
+<template>
+<div class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick" ref="el">
+ <template v-if="info">
+ <div class="titleContainer" @click="showTabsPopup" v-if="!hideTitle">
+ <MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
+ <i v-else-if="info.icon" class="icon" :class="info.icon"></i>
+
+ <div class="title">
+ <MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/>
+ <div v-else-if="info.title" class="title">{{ info.title }}</div>
+ <div class="subtitle" v-if="!narrow && info.subtitle">
+ {{ info.subtitle }}
+ </div>
+ <div class="subtitle activeTab" v-if="narrow && hasTabs">
+ {{ info.tabs.find(tab => tab.active)?.title }}
+ <i class="chevron fas fa-chevron-down"></i>
+ </div>
+ </div>
+ </div>
+ <div class="tabs" v-if="!narrow || hideTitle">
+ <button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title">
+ <i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+ <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+ </button>
+ </div>
+ </template>
+ <div class="buttons right">
+ <template v-if="info && info.actions && !narrow">
+ <template v-for="action in info.actions">
+ <MkButton class="fullButton" v-if="action.asFullButton" @click.stop="action.handler" primary><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+ <button v-else class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button>
+ </template>
+ </template>
+ <button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue';
+import * as tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { url } from '@/config';
+import { scrollToTop } from '@/scripts/scroll';
+import MkButton from '@/components/ui/button.vue';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ info: {
+ type: Object as PropType<{
+ actions?: {}[];
+ tabs?: {}[];
+ }>,
+ required: true
+ },
+ menu: {
+ required: false
+ },
+ thin: {
+ required: false,
+ default: false
+ },
+ },
+
+ setup(props) {
+ const el = ref<HTMLElement>(null);
+ const bg = ref(null);
+ const narrow = ref(false);
+ const height = ref(0);
+ const hasTabs = computed(() => {
+ return props.info.tabs && props.info.tabs.length > 0;
+ });
+ const shouldShowMenu = computed(() => {
+ if (props.info == null) return false;
+ if (props.info.actions != null && narrow.value) return true;
+ if (props.info.menu != null) return true;
+ if (props.info.share != null) return true;
+ if (props.menu != null) return true;
+ return false;
+ });
+
+ const share = () => {
+ navigator.share({
+ url: url + props.info.path,
+ ...props.info.share,
+ });
+ };
+
+ const showMenu = (ev: MouseEvent) => {
+ let menu = props.info.menu ? props.info.menu() : [];
+ if (narrow.value && props.info.actions) {
+ menu = [...props.info.actions.map(x => ({
+ text: x.text,
+ icon: x.icon,
+ action: x.handler
+ })), menu.length > 0 ? null : undefined, ...menu];
+ }
+ if (props.info.share) {
+ if (menu.length > 0) menu.push(null);
+ menu.push({
+ text: i18n.locale.share,
+ icon: 'fas fa-share-alt',
+ action: share
+ });
+ }
+ if (props.menu) {
+ if (menu.length > 0) menu.push(null);
+ menu = menu.concat(props.menu);
+ }
+ popupMenu(menu, ev.currentTarget || ev.target);
+ };
+
+ const showTabsPopup = (ev: MouseEvent) => {
+ if (!hasTabs.value) return;
+ if (!narrow.value) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ const menu = props.info.tabs.map(tab => ({
+ text: tab.title,
+ icon: tab.icon,
+ action: tab.onClick,
+ }));
+ popupMenu(menu, ev.currentTarget || ev.target);
+ };
+
+ const preventDrag = (ev: TouchEvent) => {
+ ev.stopPropagation();
+ };
+
+ const onClick = () => {
+ scrollToTop(el.value, { behavior: 'smooth' });
+ };
+
+ const calcBg = () => {
+ const rawBg = props.info?.bg || 'var(--bg)';
+ const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ tinyBg.setAlpha(0.85);
+ bg.value = tinyBg.toRgbString();
+ };
+
+ onMounted(() => {
+ calcBg();
+ globalEvents.on('themeChanged', calcBg);
+ onUnmounted(() => {
+ globalEvents.off('themeChanged', calcBg);
+ });
+
+ if (el.value.parentElement) {
+ narrow.value = el.value.parentElement.offsetWidth < 500;
+ const ro = new ResizeObserver((entries, observer) => {
+ if (el.value) {
+ narrow.value = el.value.parentElement.offsetWidth < 500;
+ }
+ });
+ ro.observe(el.value.parentElement);
+ onUnmounted(() => {
+ ro.disconnect();
+ });
+ }
+ });
+
+ return {
+ el,
+ bg,
+ narrow,
+ height,
+ hasTabs,
+ shouldShowMenu,
+ share,
+ showMenu,
+ showTabsPopup,
+ preventDrag,
+ onClick,
+ hideTitle: inject('shouldOmitHeaderTitle', false),
+ thin_: props.thin || inject('shouldHeaderThin', false)
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkb {
+ --height: 60px;
+ display: flex;
+ position: sticky;
+ top: var(--stickyTop, 0);
+ z-index: 1000;
+ width: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-bottom: solid 0.5px var(--divider);
+
+ &.thin {
+ --height: 50px;
+
+ > .buttons {
+ > .button {
+ font-size: 0.9em;
+ }
+ }
+ }
+
+ &.slim {
+ text-align: center;
+
+ > .titleContainer {
+ flex: 1;
+ margin: 0 auto;
+ margin-left: var(--height);
+
+ > *:first-child {
+ margin-left: auto;
+ }
+
+ > *:last-child {
+ margin-right: auto;
+ }
+ }
+ }
+
+ > .buttons {
+ --margin: 8px;
+ display: flex;
+ align-items: center;
+ height: var(--height);
+ margin: 0 var(--margin);
+
+ &.right {
+ margin-left: auto;
+ }
+
+ &:empty {
+ width: var(--height);
+ }
+
+ > .button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: calc(var(--height) - (var(--margin) * 2));
+ width: calc(var(--height) - (var(--margin) * 2));
+ box-sizing: border-box;
+ position: relative;
+ border-radius: 5px;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &.highlighted {
+ color: var(--accent);
+ }
+ }
+
+ > .fullButton {
+ & + .fullButton {
+ margin-left: 12px;
+ }
+ }
+ }
+
+ > .titleContainer {
+ display: flex;
+ align-items: center;
+ overflow: auto;
+ white-space: nowrap;
+ text-align: left;
+ font-weight: bold;
+ flex-shrink: 0;
+ margin-left: 24px;
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: bottom;
+ margin: 0 8px;
+ pointer-events: none;
+ }
+
+ > .icon {
+ margin-right: 8px;
+ }
+
+ > .title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.1;
+
+ > .subtitle {
+ opacity: 0.6;
+ font-size: 0.8em;
+ font-weight: normal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.activeTab {
+ text-align: center;
+
+ > .chevron {
+ display: inline-block;
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+ }
+
+ > .tabs {
+ margin-left: 16px;
+ font-size: 0.8em;
+ overflow: auto;
+ white-space: nowrap;
+
+ > .tab {
+ display: inline-block;
+ position: relative;
+ padding: 0 10px;
+ height: 100%;
+ font-weight: normal;
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &.active {
+ opacity: 1;
+
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: 0 auto;
+ width: 100%;
+ height: 3px;
+ background: var(--accent);
+ }
+ }
+
+ > .icon + .title {
+ margin-left: 8px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/i18n.ts b/packages/client/src/components/global/i18n.ts
new file mode 100644
index 0000000000..abf0c96856
--- /dev/null
+++ b/packages/client/src/components/global/i18n.ts
@@ -0,0 +1,42 @@
+import { h, defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ src: {
+ type: String,
+ required: true,
+ },
+ tag: {
+ type: String,
+ required: false,
+ default: 'span',
+ },
+ textTag: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ render() {
+ let str = this.src;
+ const parsed = [] as (string | { arg: string; })[];
+ while (true) {
+ const nextBracketOpen = str.indexOf('{');
+ const nextBracketClose = str.indexOf('}');
+
+ if (nextBracketOpen === -1) {
+ parsed.push(str);
+ break;
+ } else {
+ if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen));
+ parsed.push({
+ arg: str.substring(nextBracketOpen + 1, nextBracketClose)
+ });
+ }
+
+ str = str.substr(nextBracketClose + 1);
+ }
+
+ return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]()));
+ }
+});
diff --git a/packages/client/src/components/global/loading.vue b/packages/client/src/components/global/loading.vue
new file mode 100644
index 0000000000..7bde53c12e
--- /dev/null
+++ b/packages/client/src/components/global/loading.vue
@@ -0,0 +1,92 @@
+<template>
+<div class="yxspomdl" :class="{ inline, colored, mini }">
+ <div class="ring"></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ colored: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ mini: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes ring {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.yxspomdl {
+ padding: 32px;
+ text-align: center;
+ cursor: wait;
+
+ --size: 48px;
+
+ &.colored {
+ color: var(--accent);
+ }
+
+ &.inline {
+ display: inline;
+ padding: 0;
+ --size: 32px;
+ }
+
+ &.mini {
+ padding: 16px;
+ --size: 32px;
+ }
+
+ > .ring {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+
+ &:before,
+ &:after {
+ content: " ";
+ display: block;
+ box-sizing: border-box;
+ width: var(--size);
+ height: var(--size);
+ border-radius: 50%;
+ border: solid 4px;
+ }
+
+ &:before {
+ border-color: currentColor;
+ opacity: 0.3;
+ }
+
+ &:after {
+ position: absolute;
+ top: 0;
+ border-color: currentColor transparent transparent transparent;
+ animation: ring 0.5s linear infinite;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/misskey-flavored-markdown.vue b/packages/client/src/components/global/misskey-flavored-markdown.vue
new file mode 100644
index 0000000000..ab20404909
--- /dev/null
+++ b/packages/client/src/components/global/misskey-flavored-markdown.vue
@@ -0,0 +1,157 @@
+<template>
+<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MfmCore from '@/components/mfm';
+
+export default defineComponent({
+ components: {
+ MfmCore
+ }
+});
+</script>
+
+<style lang="scss">
+._mfm_blur_ {
+ filter: blur(6px);
+ transition: filter 0.3s;
+
+ &:hover {
+ filter: blur(0px);
+ }
+}
+
+@keyframes mfm-spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes mfm-spinX {
+ 0% { transform: perspective(128px) rotateX(0deg); }
+ 100% { transform: perspective(128px) rotateX(360deg); }
+}
+
+@keyframes mfm-spinY {
+ 0% { transform: perspective(128px) rotateY(0deg); }
+ 100% { transform: perspective(128px) rotateY(360deg); }
+}
+
+@keyframes mfm-jump {
+ 0% { transform: translateY(0); }
+ 25% { transform: translateY(-16px); }
+ 50% { transform: translateY(0); }
+ 75% { transform: translateY(-8px); }
+ 100% { transform: translateY(0); }
+}
+
+@keyframes mfm-bounce {
+ 0% { transform: translateY(0) scale(1, 1); }
+ 25% { transform: translateY(-16px) scale(1, 1); }
+ 50% { transform: translateY(0) scale(1, 1); }
+ 75% { transform: translateY(0) scale(1.5, 0.75); }
+ 100% { transform: translateY(0) scale(1, 1); }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-twitch {
+ 0% { transform: translate(7px, -2px) }
+ 5% { transform: translate(-3px, 1px) }
+ 10% { transform: translate(-7px, -1px) }
+ 15% { transform: translate(0px, -1px) }
+ 20% { transform: translate(-8px, 6px) }
+ 25% { transform: translate(-4px, -3px) }
+ 30% { transform: translate(-4px, -6px) }
+ 35% { transform: translate(-8px, -8px) }
+ 40% { transform: translate(4px, 6px) }
+ 45% { transform: translate(-3px, 1px) }
+ 50% { transform: translate(2px, -10px) }
+ 55% { transform: translate(-7px, 0px) }
+ 60% { transform: translate(-2px, 4px) }
+ 65% { transform: translate(3px, -8px) }
+ 70% { transform: translate(6px, 7px) }
+ 75% { transform: translate(-7px, -2px) }
+ 80% { transform: translate(-7px, -8px) }
+ 85% { transform: translate(9px, 3px) }
+ 90% { transform: translate(-3px, -2px) }
+ 95% { transform: translate(-10px, 2px) }
+ 100% { transform: translate(-2px, -6px) }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-shake {
+ 0% { transform: translate(-3px, -1px) rotate(-8deg) }
+ 5% { transform: translate(0px, -1px) rotate(-10deg) }
+ 10% { transform: translate(1px, -3px) rotate(0deg) }
+ 15% { transform: translate(1px, 1px) rotate(11deg) }
+ 20% { transform: translate(-2px, 1px) rotate(1deg) }
+ 25% { transform: translate(-1px, -2px) rotate(-2deg) }
+ 30% { transform: translate(-1px, 2px) rotate(-3deg) }
+ 35% { transform: translate(2px, 1px) rotate(6deg) }
+ 40% { transform: translate(-2px, -3px) rotate(-9deg) }
+ 45% { transform: translate(0px, -1px) rotate(-12deg) }
+ 50% { transform: translate(1px, 2px) rotate(10deg) }
+ 55% { transform: translate(0px, -3px) rotate(8deg) }
+ 60% { transform: translate(1px, -1px) rotate(8deg) }
+ 65% { transform: translate(0px, -1px) rotate(-7deg) }
+ 70% { transform: translate(-1px, -3px) rotate(6deg) }
+ 75% { transform: translate(0px, -2px) rotate(4deg) }
+ 80% { transform: translate(-2px, -1px) rotate(3deg) }
+ 85% { transform: translate(1px, -3px) rotate(-10deg) }
+ 90% { transform: translate(1px, 0px) rotate(3deg) }
+ 95% { transform: translate(-2px, 0px) rotate(-3deg) }
+ 100% { transform: translate(2px, 1px) rotate(2deg) }
+}
+
+@keyframes mfm-rubberBand {
+ from { transform: scale3d(1, 1, 1); }
+ 30% { transform: scale3d(1.25, 0.75, 1); }
+ 40% { transform: scale3d(0.75, 1.25, 1); }
+ 50% { transform: scale3d(1.15, 0.85, 1); }
+ 65% { transform: scale3d(0.95, 1.05, 1); }
+ 75% { transform: scale3d(1.05, 0.95, 1); }
+ to { transform: scale3d(1, 1, 1); }
+}
+
+@keyframes mfm-rainbow {
+ 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
+ 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
+}
+</style>
+
+<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(--fg);
+ border-left: solid 3px var(--fg);
+ opacity: 0.7;
+ }
+
+ ::v-deep(pre) {
+ font-size: 0.8em;
+ }
+
+ > ::v-deep(code) {
+ font-size: 0.8em;
+ word-break: break-all;
+ padding: 4px 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue
new file mode 100644
index 0000000000..1129d54c71
--- /dev/null
+++ b/packages/client/src/components/global/spacer.vue
@@ -0,0 +1,76 @@
+<template>
+<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }">
+ <div ref="content" :class="$style.content">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+
+export default defineComponent({
+ props: {
+ contentMax: {
+ type: Number,
+ required: false,
+ default: null,
+ }
+ },
+
+ setup(props, context) {
+ let ro: ResizeObserver;
+ const root = ref<HTMLElement>(null);
+ const content = ref<HTMLElement>(null);
+ const margin = ref(0);
+ const adjust = (rect: { width: number; height: number; }) => {
+ if (rect.width > (props.contentMax || 500)) {
+ margin.value = 32;
+ } else {
+ margin.value = 12;
+ }
+ };
+
+ onMounted(() => {
+ ro = new ResizeObserver((entries) => {
+ /* iOSが対応していない
+ adjust({
+ width: entries[0].borderBoxSize[0].inlineSize,
+ height: entries[0].borderBoxSize[0].blockSize,
+ });
+ */
+ adjust({
+ width: root.value.offsetWidth,
+ height: root.value.offsetHeight,
+ });
+ });
+ ro.observe(root.value);
+
+ if (props.contentMax) {
+ content.value.style.maxWidth = `${props.contentMax}px`;
+ }
+ });
+
+ onUnmounted(() => {
+ ro.disconnect();
+ });
+
+ return {
+ root,
+ content,
+ margin,
+ };
+ },
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ box-sizing: border-box;
+ width: 100%;
+}
+
+.content {
+ margin: 0 auto;
+}
+</style>
diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue
new file mode 100644
index 0000000000..859b2c1d73
--- /dev/null
+++ b/packages/client/src/components/global/sticky-container.vue
@@ -0,0 +1,74 @@
+<template>
+<div ref="rootEl">
+ <slot name="header"></slot>
+ <div ref="bodyEl">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+
+export default defineComponent({
+ props: {
+ autoSticky: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ setup(props, context) {
+ const rootEl = ref<HTMLElement>(null);
+ const bodyEl = ref<HTMLElement>(null);
+
+ const calc = () => {
+ const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
+
+ const header = rootEl.value.children[0];
+ if (header === bodyEl.value) {
+ bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
+ } else {
+ bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
+
+ if (props.autoSticky) {
+ header.style.setProperty('--stickyTop', currentStickyTop);
+ header.style.position = 'sticky';
+ header.style.top = 'var(--stickyTop)';
+ header.style.zIndex = '1';
+ }
+ }
+ };
+
+ onMounted(() => {
+ calc();
+
+ const observer = new MutationObserver(() => {
+ setTimeout(() => {
+ calc();
+ }, 100);
+ });
+
+ observer.observe(rootEl.value, {
+ attributes: false,
+ childList: true,
+ subtree: false,
+ });
+
+ onUnmounted(() => {
+ observer.disconnect();
+ });
+ });
+
+ return {
+ rootEl,
+ bodyEl,
+ };
+ },
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue
new file mode 100644
index 0000000000..6a330a2307
--- /dev/null
+++ b/packages/client/src/components/global/time.vue
@@ -0,0 +1,73 @@
+<template>
+<time :title="absolute">
+ <template v-if="mode == 'relative'">{{ relative }}</template>
+ <template v-else-if="mode == 'absolute'">{{ absolute }}</template>
+ <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template>
+</time>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ 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.$ts._ago.justNow :
+ ago < -1 ? this.$ts._ago.future :
+ this.$ts._ago.unknown);
+ }
+ },
+ created() {
+ if (this.mode == 'relative' || this.mode == 'detail') {
+ this.tickId = window.requestAnimationFrame(this.tick);
+ }
+ },
+ unmounted() {
+ if (this.mode === 'relative' || this.mode === 'detail') {
+ window.clearTimeout(this.tickId);
+ }
+ },
+ methods: {
+ tick() {
+ // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
+ this.now = new Date();
+
+ this.tickId = setTimeout(() => {
+ window.requestAnimationFrame(this.tick);
+ }, 10000);
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/global/url.vue b/packages/client/src/components/global/url.vue
new file mode 100644
index 0000000000..092fe6620c
--- /dev/null
+++ b/packages/client/src/components/global/url.vue
@@ -0,0 +1,142 @@
+<template>
+<component :is="self ? 'MkA' : 'a'" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
+ @mouseover="onMouseover"
+ @mouseleave="onMouseleave"
+ @contextmenu.stop="() => {}"
+>
+ <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>
+ <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
+</component>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode as decodePunycode } from 'punycode/';
+import { url as local } from '@/config';
+import { isDeviceTouch } from '@/scripts/is-device-touch';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ rel: {
+ type: String,
+ required: false,
+ }
+ },
+ data() {
+ const self = this.url.startsWith(local);
+ return {
+ local,
+ schema: null as string | null,
+ hostname: null as string | null,
+ port: null as string | null,
+ pathname: null as string | null,
+ query: null as string | null,
+ hash: null as string | null,
+ self: self,
+ attr: self ? 'to' : 'href',
+ target: self ? null : '_blank',
+ showTimer: null,
+ hideTimer: null,
+ checkTimer: null,
+ close: null,
+ };
+ },
+ 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);
+ },
+ methods: {
+ async showPreview() {
+ if (!document.body.contains(this.$el)) return;
+ if (this.close) return;
+
+ const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), {
+ url: this.url,
+ source: this.$el
+ });
+
+ this.close = () => {
+ dispose();
+ };
+
+ this.checkTimer = setInterval(() => {
+ if (!document.body.contains(this.$el)) this.closePreview();
+ }, 1000);
+ },
+ closePreview() {
+ if (this.close) {
+ clearInterval(this.checkTimer);
+ this.close();
+ this.close = null;
+ }
+ },
+ onMouseover() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.showTimer = setTimeout(this.showPreview, 500);
+ },
+ onMouseleave() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.hideTimer = setTimeout(this.closePreview, 500);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ieqqeuvs {
+ word-break: break-all;
+
+ > .icon {
+ padding-left: 2px;
+ font-size: .9em;
+ }
+
+ > .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/packages/client/src/components/global/user-name.vue b/packages/client/src/components/global/user-name.vue
new file mode 100644
index 0000000000..bc93a8ea30
--- /dev/null
+++ b/packages/client/src/components/global/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 { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ nowrap: {
+ type: Boolean,
+ default: true
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/components/google.vue b/packages/client/src/components/google.vue
new file mode 100644
index 0000000000..c48feffbf1
--- /dev/null
+++ b/packages/client/src/components/google.vue
@@ -0,0 +1,64 @@
+<template>
+<div class="mk-google">
+ <input type="search" v-model="query" :placeholder="q">
+ <button @click="search"><i class="fas fa-search"></i> {{ $ts.search }}</button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ q: {
+ type: String,
+ required: true,
+ }
+ },
+ data() {
+ return {
+ query: null,
+ };
+ },
+ mounted() {
+ this.query = this.q;
+ },
+ methods: {
+ search() {
+ window.open(`https://www.google.com/search?q=${this.query}`, '_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;
+ border: solid 1px var(--divider);
+ border-radius: 4px 0 0 4px;
+ -webkit-appearance: textfield;
+ }
+
+ > button {
+ flex-shrink: 0;
+ margin: 0;
+ padding: 0 16px;
+ border: solid 1px var(--divider);
+ border-left: none;
+ border-radius: 0 4px 4px 0;
+
+ &:active {
+ box-shadow: 0 2px 4px rgba(#000, 0.15) inset;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/image-viewer.vue b/packages/client/src/components/image-viewer.vue
new file mode 100644
index 0000000000..fc28c30b56
--- /dev/null
+++ b/packages/client/src/components/image-viewer.vue
@@ -0,0 +1,85 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="xubzgfga">
+ <header>{{ image.name }}</header>
+ <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
+ <footer>
+ <span>{{ image.type }}</span>
+ <span>{{ bytes(image.size) }}</span>
+ <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
+ </footer>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkModal from '@/components/ui/modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ props: {
+ image: {
+ type: Object,
+ required: true
+ },
+ },
+
+ emits: ['closed'],
+
+ methods: {
+ bytes,
+ number,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xubzgfga {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > header,
+ > footer {
+ align-self: center;
+ display: inline-block;
+ padding: 6px 9px;
+ font-size: 90%;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 6px;
+ color: #fff;
+ }
+
+ > header {
+ margin-bottom: 8px;
+ opacity: 0.9;
+ }
+
+ > img {
+ display: block;
+ flex: 1;
+ min-height: 0;
+ object-fit: contain;
+ width: 100%;
+ cursor: zoom-out;
+ image-orientation: from-image;
+ }
+
+ > footer {
+ margin-top: 8px;
+ opacity: 0.8;
+
+ > span + span {
+ margin-left: 0.5em;
+ padding-left: 0.5em;
+ border-left: solid 1px rgba(255, 255, 255, 0.5);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/img-with-blurhash.vue
new file mode 100644
index 0000000000..7e80b00208
--- /dev/null
+++ b/packages/client/src/components/img-with-blurhash.vue
@@ -0,0 +1,100 @@
+<template>
+<div class="xubzgfgb" :class="{ cover }" :title="title">
+ <canvas ref="canvas" :width="size" :height="size" :title="title" v-if="!loaded"/>
+ <img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { decode } from 'blurhash';
+
+export default defineComponent({
+ props: {
+ src: {
+ type: String,
+ required: false,
+ default: null
+ },
+ hash: {
+ type: String,
+ required: true
+ },
+ alt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ title: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 64
+ },
+ cover: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }
+ },
+
+ data() {
+ return {
+ loaded: false,
+ };
+ },
+
+ mounted() {
+ this.draw();
+ },
+
+ methods: {
+ draw() {
+ if (this.hash == null) return;
+ const pixels = decode(this.hash, this.size, this.size);
+ const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d');
+ const imageData = ctx!.createImageData(this.size, this.size);
+ imageData.data.set(pixels);
+ ctx!.putImageData(imageData, 0, 0);
+ },
+
+ onLoad() {
+ this.loaded = true;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xubzgfgb {
+ position: relative;
+ width: 100%;
+ height: 100%;
+
+ > canvas,
+ > img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ > canvas {
+ position: absolute;
+ object-fit: cover;
+ }
+
+ > img {
+ object-fit: contain;
+ }
+
+ &.cover {
+ > img {
+ object-fit: cover;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts
new file mode 100644
index 0000000000..2340b228f8
--- /dev/null
+++ b/packages/client/src/components/index.ts
@@ -0,0 +1,37 @@
+import { App } from 'vue';
+
+import mfm from './global/misskey-flavored-markdown.vue';
+import a from './global/a.vue';
+import acct from './global/acct.vue';
+import avatar from './global/avatar.vue';
+import emoji from './global/emoji.vue';
+import userName from './global/user-name.vue';
+import ellipsis from './global/ellipsis.vue';
+import time from './global/time.vue';
+import url from './global/url.vue';
+import i18n from './global/i18n';
+import loading from './global/loading.vue';
+import error from './global/error.vue';
+import ad from './global/ad.vue';
+import header from './global/header.vue';
+import spacer from './global/spacer.vue';
+import stickyContainer from './global/sticky-container.vue';
+
+export default function(app: App) {
+ app.component('I18n', i18n);
+ app.component('Mfm', mfm);
+ app.component('MkA', a);
+ app.component('MkAcct', acct);
+ app.component('MkAvatar', avatar);
+ app.component('MkEmoji', emoji);
+ app.component('MkUserName', userName);
+ app.component('MkEllipsis', ellipsis);
+ app.component('MkTime', time);
+ app.component('MkUrl', url);
+ app.component('MkLoading', loading);
+ app.component('MkError', error);
+ app.component('MkAd', ad);
+ app.component('MkHeader', header);
+ app.component('MkSpacer', spacer);
+ app.component('MkStickyContainer', stickyContainer);
+}
diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue
new file mode 100644
index 0000000000..bc62998a4a
--- /dev/null
+++ b/packages/client/src/components/instance-stats.vue
@@ -0,0 +1,80 @@
+<template>
+<div class="zbcjwnqg" style="margin-top: -8px;">
+ <div class="selects" style="display: flex;">
+ <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+ <optgroup :label="$ts.federation">
+ <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option>
+ <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option>
+ </optgroup>
+ <optgroup :label="$ts.users">
+ <option value="users">{{ $ts._charts.usersIncDec }}</option>
+ <option value="users-total">{{ $ts._charts.usersTotal }}</option>
+ <option value="active-users">{{ $ts._charts.activeUsers }}</option>
+ </optgroup>
+ <optgroup :label="$ts.notes">
+ <option value="notes">{{ $ts._charts.notesIncDec }}</option>
+ <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option>
+ <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option>
+ <option value="notes-total">{{ $ts._charts.notesTotal }}</option>
+ </optgroup>
+ <optgroup :label="$ts.drive">
+ <option value="drive-files">{{ $ts._charts.filesIncDec }}</option>
+ <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option>
+ <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option>
+ <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
+ </optgroup>
+ </MkSelect>
+ <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
+ <option value="hour">{{ $ts.perHour }}</option>
+ <option value="day">{{ $ts.perDay }}</option>
+ </MkSelect>
+ </div>
+ <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref, watch } from 'vue';
+import MkSelect from '@/components/form/select.vue';
+import MkChart from '@/components/chart.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ MkSelect,
+ MkChart,
+ },
+
+ props: {
+ chartLimit: {
+ type: Number,
+ required: false,
+ default: 90
+ },
+ detailed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ setup() {
+ const chartSpan = ref<'hour' | 'day'>('hour');
+ const chartSrc = ref('notes');
+
+ return {
+ chartSrc,
+ chartSpan,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.zbcjwnqg {
+ > .selects {
+ padding: 8px 16px 0 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/instance-ticker.vue b/packages/client/src/components/instance-ticker.vue
new file mode 100644
index 0000000000..1ce5a1c2c1
--- /dev/null
+++ b/packages/client/src/components/instance-ticker.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="hpaizdrt" :style="bg">
+ <img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/>
+ <span class="name">{{ info.name }}</span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { instanceName } from '@/config';
+
+export default defineComponent({
+ props: {
+ instance: {
+ type: Object,
+ required: false
+ },
+ },
+
+ data() {
+ return {
+ info: this.instance || {
+ faviconUrl: '/favicon.ico',
+ name: instanceName,
+ themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
+ }
+ }
+ },
+
+ computed: {
+ bg(): any {
+ const themeColor = this.info.themeColor || '#777777';
+ return {
+ background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
+ };
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hpaizdrt {
+ $height: 1.1rem;
+
+ height: $height;
+ border-radius: 4px 0 0 4px;
+ overflow: hidden;
+ color: #fff;
+
+ > .icon {
+ height: 100%;
+ }
+
+ > .name {
+ margin-left: 4px;
+ line-height: $height;
+ font-size: 0.9em;
+ vertical-align: top;
+ font-weight: bold;
+ }
+}
+</style>
diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue
new file mode 100644
index 0000000000..09f5f89f90
--- /dev/null
+++ b/packages/client/src/components/launch-pad.vue
@@ -0,0 +1,152 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="szkkfdyq _popup">
+ <div class="main">
+ <template v-for="item in items">
+ <button v-if="item.action" class="_button" @click="$event => { item.action($event); close(); }" v-click-anime>
+ <i class="icon" :class="item.icon"></i>
+ <div class="text">{{ item.text }}</div>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ <MkA v-else :to="item.to" @click.passive="close()" v-click-anime>
+ <i class="icon" :class="item.icon"></i>
+ <div class="text">{{ item.text }}</div>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </MkA>
+ </template>
+ </div>
+ <div class="sub">
+ <a href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()" v-click-anime>
+ <i class="fas fa-question-circle icon"></i>
+ <div class="text">{{ $ts.help }}</div>
+ </a>
+ <MkA to="/about" @click.passive="close()" v-click-anime>
+ <i class="fas fa-info-circle icon"></i>
+ <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div>
+ </MkA>
+ <MkA to="/about-misskey" @click.passive="close()" v-click-anime>
+ <img src="/static-assets/favicon.png" class="icon"/>
+ <div class="text">{{ $ts.aboutMisskey }}</div>
+ </MkA>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import { menuDef } from '@/menu';
+import { instanceName } from '@/config';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ menuDef: menuDef,
+ items: [],
+ instanceName,
+ };
+ },
+
+ computed: {
+ menu(): string[] {
+ return this.$store.state.menu;
+ },
+ },
+
+ created() {
+ this.items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
+ type: def.to ? 'link' : 'button',
+ text: this.$ts[def.title],
+ icon: def.icon,
+ to: def.to,
+ action: def.action,
+ indicate: def.indicated,
+ }));
+ },
+
+ methods: {
+ close() {
+ this.$refs.modal.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.szkkfdyq {
+ width: 100%;
+ max-height: 100%;
+ max-width: 800px;
+ padding: 32px;
+ box-sizing: border-box;
+ overflow: auto;
+ text-align: center;
+ border-radius: 16px;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ }
+
+ > .main, > .sub {
+ > * {
+ position: relative;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ vertical-align: bottom;
+ width: 128px;
+ height: 128px;
+ border-radius: var(--radius);
+
+ @media (max-width: 500px) {
+ width: 100px;
+ height: 100px;
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ text-decoration: none;
+ }
+
+ > .icon {
+ font-size: 26px;
+ height: 32px;
+ }
+
+ > .text {
+ margin-top: 8px;
+ font-size: 0.9em;
+ line-height: 1.5em;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 32px;
+ left: 32px;
+ color: var(--indicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+
+ @media (max-width: 500px) {
+ top: 16px;
+ left: 16px;
+ }
+ }
+ }
+ }
+
+ > .sub {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/components/link.vue b/packages/client/src/components/link.vue
new file mode 100644
index 0000000000..a8e096e0a0
--- /dev/null
+++ b/packages/client/src/components/link.vue
@@ -0,0 +1,92 @@
+<template>
+<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
+ @mouseover="onMouseover"
+ @mouseleave="onMouseleave"
+ :title="url"
+>
+ <slot></slot>
+ <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
+</component>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { url as local } from '@/config';
+import { isDeviceTouch } from '@/scripts/is-device-touch';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ rel: {
+ type: String,
+ required: false,
+ }
+ },
+ data() {
+ const self = this.url.startsWith(local);
+ return {
+ local,
+ self: self,
+ attr: self ? 'to' : 'href',
+ target: self ? null : '_blank',
+ showTimer: null,
+ hideTimer: null,
+ checkTimer: null,
+ close: null,
+ };
+ },
+ methods: {
+ async showPreview() {
+ if (!document.body.contains(this.$el)) return;
+ if (this.close) return;
+
+ const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), {
+ url: this.url,
+ source: this.$el
+ });
+
+ this.close = () => {
+ dispose();
+ };
+
+ this.checkTimer = setInterval(() => {
+ if (!document.body.contains(this.$el)) this.closePreview();
+ }, 1000);
+ },
+ closePreview() {
+ if (this.close) {
+ clearInterval(this.checkTimer);
+ this.close();
+ this.close = null;
+ }
+ },
+ onMouseover() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.showTimer = setTimeout(this.showPreview, 500);
+ },
+ onMouseleave() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.hideTimer = setTimeout(this.closePreview, 500);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xlcxczvw {
+ word-break: break-all;
+
+ > .icon {
+ padding-left: 2px;
+ font-size: .9em;
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-banner.vue b/packages/client/src/components/media-banner.vue
new file mode 100644
index 0000000000..2cf8c772e5
--- /dev/null
+++ b/packages/client/src/components/media-banner.vue
@@ -0,0 +1,107 @@
+<template>
+<div class="mk-media-banner">
+ <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false">
+ <span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
+ <b>{{ $ts.sensitive }}</b>
+ <span>{{ $ts.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"><i class="fas fa-download"></i></span>
+ <b>{{ media.name }}</b>
+ </a>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+ props: {
+ media: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ hide: true,
+ };
+ },
+ mounted() {
+ const audioTag = this.$refs.audio as HTMLAudioElement;
+ if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume');
+ },
+ methods: {
+ volumechange() {
+ const audioTag = this.$refs.audio as HTMLAudioElement;
+ ColdDeviceStorage.set('mediaVolume', 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/packages/client/src/components/media-caption.vue b/packages/client/src/components/media-caption.vue
new file mode 100644
index 0000000000..08a3ca2b4c
--- /dev/null
+++ b/packages/client/src/components/media-caption.vue
@@ -0,0 +1,259 @@
+<template>
+ <MkModal ref="modal" @click="done(true)" @closed="$emit('closed')">
+ <div class="container">
+ <div class="fullwidth top-caption">
+ <div class="mk-dialog">
+ <header>
+ <Mfm v-if="title" class="title" :text="title"/>
+ <span class="text-count" :class="{ over: remainingLength < 0 }">{{ remainingLength }}</span>
+ </header>
+ <textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea>
+ <div class="buttons" v-if="(showOkButton || showCancelButton)">
+ <MkButton inline @click="ok" primary :disabled="remainingLength < 0">{{ $ts.ok }}</MkButton>
+ <MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton>
+ </div>
+ </div>
+ </div>
+ <div class="hdrwpsaf fullwidth">
+ <header>{{ image.name }}</header>
+ <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
+ <footer>
+ <span>{{ image.type }}</span>
+ <span>{{ bytes(image.size) }}</span>
+ <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
+ </footer>
+ </div>
+ </div>
+ </MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { length } from 'stringz';
+import MkModal from '@/components/ui/modal.vue';
+import MkButton from '@/components/ui/button.vue';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkButton,
+ },
+
+ props: {
+ image: {
+ type: Object,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: false
+ },
+ input: {
+ required: true
+ },
+ showOkButton: {
+ type: Boolean,
+ default: true
+ },
+ showCancelButton: {
+ type: Boolean,
+ default: true
+ },
+ cancelableByBgClick: {
+ type: Boolean,
+ default: true
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ inputValue: this.input.default ? this.input.default : null
+ };
+ },
+
+ computed: {
+ remainingLength(): number {
+ if (typeof this.inputValue != "string") return 512;
+ return 512 - length(this.inputValue);
+ }
+ },
+
+ mounted() {
+ document.addEventListener('keydown', this.onKeydown);
+ },
+
+ beforeUnmount() {
+ document.removeEventListener('keydown', this.onKeydown);
+ },
+
+ methods: {
+ bytes,
+ number,
+
+ done(canceled, result?) {
+ this.$emit('done', { canceled, result });
+ this.$refs.modal.close();
+ },
+
+ async ok() {
+ if (!this.showOkButton) return;
+
+ const result = this.inputValue;
+ this.done(false, result);
+ },
+
+ cancel() {
+ this.done(true);
+ },
+
+ onBgClick() {
+ if (this.cancelableByBgClick) {
+ this.cancel();
+ }
+ },
+
+ onKeydown(e) {
+ if (e.which === 27) { // ESC
+ this.cancel();
+ }
+ },
+
+ onInputKeydown(e) {
+ if (e.which === 13) { // Enter
+ if (e.ctrlKey) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.ok();
+ }
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.container {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ flex-direction: row;
+}
+@media (max-width: 850px) {
+ .container {
+ flex-direction: column;
+ }
+ .top-caption {
+ padding-bottom: 8px;
+ }
+}
+.fullwidth {
+ width: 100%;
+ margin: auto;
+}
+.mk-dialog {
+ position: relative;
+ padding: 32px;
+ min-width: 320px;
+ max-width: 480px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+ margin: auto;
+
+ > header {
+ margin: 0 0 8px 0;
+ position: relative;
+
+ > .title {
+ font-weight: bold;
+ font-size: 20px;
+ }
+
+ > .text-count {
+ opacity: 0.7;
+ position: absolute;
+ right: 0;
+ }
+ }
+
+ > .buttons {
+ margin-top: 16px;
+
+ > * {
+ margin: 0 8px;
+ }
+ }
+
+ > 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: inherit;
+ max-width: 100%;
+ min-width: 100%;
+ min-height: 90px;
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ }
+ }
+}
+.hdrwpsaf {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > header,
+ > footer {
+ align-self: center;
+ display: inline-block;
+ padding: 6px 9px;
+ font-size: 90%;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 6px;
+ color: #fff;
+ }
+
+ > header {
+ margin-bottom: 8px;
+ opacity: 0.9;
+ }
+
+ > img {
+ display: block;
+ flex: 1;
+ min-height: 0;
+ object-fit: contain;
+ width: 100%;
+ cursor: zoom-out;
+ image-orientation: from-image;
+ }
+
+ > footer {
+ margin-top: 8px;
+ opacity: 0.8;
+
+ > span + span {
+ margin-left: 0.5em;
+ padding-left: 0.5em;
+ border-left: solid 1px rgba(255, 255, 255, 0.5);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-image.vue b/packages/client/src/components/media-image.vue
new file mode 100644
index 0000000000..8843b63207
--- /dev/null
+++ b/packages/client/src/components/media-image.vue
@@ -0,0 +1,155 @@
+<template>
+<div class="qjewsnkg" v-if="hide" @click="hide = false">
+ <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
+ <div class="text">
+ <div>
+ <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
+ <span>{{ $ts.clickToShow }}</span>
+ </div>
+ </div>
+</div>
+<div class="gqnyydlz" :style="{ background: color }" v-else>
+ <a
+ :href="image.url"
+ :title="image.name"
+ >
+ <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/>
+ <div class="gif" v-if="image.type === 'image/gif'">GIF</div>
+ </a>
+ <i class="fas fa-eye-slash" @click="hide = true"></i>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+import ImageViewer from './image-viewer.vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ ImgWithBlurhash
+ },
+ props: {
+ image: {
+ type: Object,
+ required: true
+ },
+ raw: {
+ default: false
+ }
+ },
+ data() {
+ return {
+ hide: true,
+ color: null,
+ };
+ },
+ computed: {
+ url(): any {
+ let url = this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(this.image.thumbnailUrl)
+ : this.image.thumbnailUrl;
+
+ if (this.raw || this.$store.state.loadRawImages) {
+ url = this.image.url;
+ }
+
+ return url;
+ }
+ },
+ created() {
+ // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
+ this.$watch('image', () => {
+ this.hide = (this.$store.state.nsfw === 'force') ? true : this.image.isSensitive && (this.$store.state.nsfw !== 'ignore');
+ if (this.image.blurhash) {
+ this.color = extractAvgColorFromBlurhash(this.image.blurhash);
+ }
+ }, {
+ deep: true,
+ immediate: true,
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.qjewsnkg {
+ position: relative;
+
+ > .bg {
+ filter: brightness(0.5);
+ }
+
+ > .text {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ > div {
+ display: table-cell;
+ text-align: center;
+ font-size: 0.8em;
+ color: #fff;
+
+ > * {
+ display: block;
+ }
+ }
+ }
+}
+
+.gqnyydlz {
+ position: relative;
+ border: solid 0.5px var(--divider);
+
+ > i {
+ display: block;
+ position: absolute;
+ border-radius: 6px;
+ background-color: var(--fg);
+ color: var(--accentLighten);
+ font-size: 14px;
+ opacity: .5;
+ padding: 3px 6px;
+ text-align: center;
+ cursor: pointer;
+ top: 12px;
+ right: 12px;
+ }
+
+ > a {
+ display: block;
+ cursor: zoom-in;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+ background-position: center;
+ background-size: contain;
+ background-repeat: no-repeat;
+
+ > .gif {
+ background-color: var(--fg);
+ border-radius: 6px;
+ color: var(--accentLighten);
+ display: inline-block;
+ font-size: 14px;
+ font-weight: bold;
+ left: 12px;
+ opacity: .5;
+ padding: 0 6px;
+ text-align: center;
+ top: 12px;
+ pointer-events: none;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-list.vue b/packages/client/src/components/media-list.vue
new file mode 100644
index 0000000000..51eaa86f35
--- /dev/null
+++ b/packages/client/src/components/media-list.vue
@@ -0,0 +1,167 @@
+<template>
+<div class="hoawjimk">
+ <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :media="media" :key="media.id"/>
+ <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
+ <div :data-count="mediaList.filter(media => previewable(media)).length" ref="gallery">
+ <template v-for="media in mediaList">
+ <XVideo :video="media" :key="media.id" v-if="media.type.startsWith('video')"/>
+ <XImage class="image" :data-id="media.id" :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/>
+ </template>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, ref } from 'vue';
+import * as misskey from 'misskey-js';
+import PhotoSwipeLightbox from 'photoswipe/dist/photoswipe-lightbox.esm.js';
+import PhotoSwipe from 'photoswipe/dist/photoswipe.esm.js';
+import 'photoswipe/dist/photoswipe.css';
+import XBanner from './media-banner.vue';
+import XImage from './media-image.vue';
+import XVideo from './media-video.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ XBanner,
+ XImage,
+ XVideo,
+ },
+ props: {
+ mediaList: {
+ type: Array as PropType<misskey.entities.DriveFile[]>,
+ required: true,
+ },
+ raw: {
+ default: false
+ },
+ },
+ setup(props) {
+ const gallery = ref(null);
+
+ onMounted(() => {
+ const lightbox = new PhotoSwipeLightbox({
+ dataSource: props.mediaList.filter(media => media.type.startsWith('image')).map(media => ({
+ src: media.url,
+ w: media.properties.width,
+ h: media.properties.height,
+ alt: media.name,
+ })),
+ gallery: gallery.value,
+ children: '.image',
+ thumbSelector: '.image',
+ pswpModule: PhotoSwipe
+ });
+
+ lightbox.on('itemData', (e) => {
+ const { itemData } = e;
+
+ // element is children
+ const { element } = itemData;
+
+ const id = element.dataset.id;
+ const file = props.mediaList.find(media => media.id === id);
+
+ itemData.src = file.url;
+ itemData.w = Number(file.properties.width);
+ itemData.h = Number(file.properties.height);
+ itemData.msrc = file.thumbnailUrl;
+ itemData.thumbCropped = true;
+ });
+
+ lightbox.init();
+ });
+
+ const previewable = (file: misskey.entities.DriveFile): boolean => {
+ return file.type.startsWith('video') || file.type.startsWith('image');
+ };
+
+ return {
+ previewable,
+ gallery,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hoawjimk {
+ > .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: 6px;
+ }
+
+ &[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/packages/client/src/components/media-video.vue b/packages/client/src/components/media-video.vue
new file mode 100644
index 0000000000..aa885bd564
--- /dev/null
+++ b/packages/client/src/components/media-video.vue
@@ -0,0 +1,97 @@
+<template>
+<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="hide" @click="hide = false">
+ <div>
+ <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
+ <span>{{ $ts.clickToShow }}</span>
+ </div>
+</div>
+<div class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else>
+ <video
+ :poster="video.thumbnailUrl"
+ :title="video.name"
+ preload="none"
+ controls
+ @contextmenu.stop
+ >
+ <source
+ :src="video.url"
+ :type="video.type"
+ >
+ </video>
+ <i class="fas fa-eye-slash" @click="hide = true"></i>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ video: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ hide: true,
+ };
+ },
+ created() {
+ this.hide = (this.$store.state.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.nsfw !== 'ignore');
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.kkjnbbplepmiyuadieoenjgutgcmtsvu {
+ position: relative;
+
+ > i {
+ display: block;
+ position: absolute;
+ border-radius: 6px;
+ background-color: var(--fg);
+ color: var(--accentLighten);
+ font-size: 14px;
+ opacity: .5;
+ padding: 3px 6px;
+ text-align: center;
+ cursor: pointer;
+ top: 12px;
+ right: 12px;
+ }
+
+ > video {
+ 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/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue
new file mode 100644
index 0000000000..a5be3fab22
--- /dev/null
+++ b/packages/client/src/components/mention.vue
@@ -0,0 +1,84 @@
+<template>
+<MkA v-if="url.startsWith('/')" class="ldlomzub" :class="{ isMe }" :to="url" v-user-preview="canonical" :style="{ background: bg }">
+ <img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
+ <span class="main">
+ <span class="username">@{{ username }}</span>
+ <span class="host" v-if="(host != localHost) || $store.state.showFullAcct">@{{ toUnicode(host) }}</span>
+ </span>
+</MkA>
+<a v-else class="ldlomzub" :href="url" target="_blank" rel="noopener" :style="{ background: bg }">
+ <span class="main">
+ <span class="username">@{{ username }}</span>
+ <span class="host">@{{ toUnicode(host) }}</span>
+ </span>
+</a>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+import { toUnicode } from 'punycode';
+import { host as localHost } from '@/config';
+import { $i } from '@/account';
+
+export default defineComponent({
+ props: {
+ username: {
+ type: String,
+ required: true
+ },
+ host: {
+ type: String,
+ required: true
+ }
+ },
+
+ setup(props) {
+ const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
+
+ const url = `/${canonical}`;
+
+ const isMe = $i && (
+ `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
+ );
+
+ const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
+ bg.setAlpha(0.1);
+
+ return {
+ localHost,
+ isMe,
+ url,
+ canonical,
+ toUnicode,
+ bg: bg.toRgbString(),
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ldlomzub {
+ display: inline-block;
+ padding: 4px 8px 4px 4px;
+ border-radius: 999px;
+ color: var(--mention);
+
+ &.isMe {
+ color: var(--mentionMe);
+ }
+
+ > .icon {
+ width: 1.5em;
+ margin: 0 0.2em 0 0;
+ vertical-align: bottom;
+ border-radius: 100%;
+ }
+
+ > .main {
+ > .host {
+ opacity: 0.5;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts
new file mode 100644
index 0000000000..d41cf6fc2b
--- /dev/null
+++ b/packages/client/src/components/mfm.ts
@@ -0,0 +1,321 @@
+import { VNode, defineComponent, h } from 'vue';
+import * as mfm from 'mfm-js';
+import MkUrl from '@/components/global/url.vue';
+import MkLink from '@/components/link.vue';
+import MkMention from '@/components/mention.vue';
+import MkEmoji from '@/components/global/emoji.vue';
+import { concat } from '@/scripts/array';
+import MkFormula from '@/components/formula.vue';
+import MkCode from '@/components/code.vue';
+import MkGoogle from '@/components/google.vue';
+import MkSparkle from '@/components/sparkle.vue';
+import MkA from '@/components/global/a.vue';
+import { host } from '@/config';
+import { MFM_TAGS } from '@/scripts/mfm-tags';
+
+export default defineComponent({
+ 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() {
+ if (this.text == null || this.text == '') return;
+
+ const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text, { fnNameList: MFM_TAGS });
+
+ const validTime = (t: string | null | undefined) => {
+ if (t == null) return null;
+ return t.match(/^[0-9.]+s$/) ? t : null;
+ };
+
+ const genEl = (ast: mfm.MfmNode[]) => concat(ast.map((token): VNode[] => {
+ switch (token.type) {
+ case 'text': {
+ const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+
+ if (!this.plain) {
+ const res = [];
+ for (const t of text.split('\n')) {
+ res.push(h('br'));
+ res.push(t);
+ }
+ res.shift();
+ return res;
+ } else {
+ return [text.replace(/\n/g, ' ')];
+ }
+ }
+
+ case 'bold': {
+ return [h('b', genEl(token.children))];
+ }
+
+ case 'strike': {
+ return [h('del', genEl(token.children))];
+ }
+
+ case 'italic': {
+ return h('i', {
+ style: 'font-style: oblique;'
+ }, genEl(token.children));
+ }
+
+ case 'fn': {
+ // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
+ let style;
+ switch (token.props.name) {
+ case 'tada': {
+ style = `font-size: 150%;` + (this.$store.state.animatedMfm ? 'animation: tada 1s linear infinite both;' : '');
+ break;
+ }
+ case 'jelly': {
+ const speed = validTime(token.props.args.speed) || '1s';
+ style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
+ break;
+ }
+ case 'twitch': {
+ const speed = validTime(token.props.args.speed) || '0.5s';
+ style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
+ break;
+ }
+ case 'shake': {
+ const speed = validTime(token.props.args.speed) || '0.5s';
+ style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
+ break;
+ }
+ case 'spin': {
+ const direction =
+ token.props.args.left ? 'reverse' :
+ token.props.args.alternate ? 'alternate' :
+ 'normal';
+ const anime =
+ token.props.args.x ? 'mfm-spinX' :
+ token.props.args.y ? 'mfm-spinY' :
+ 'mfm-spin';
+ const speed = validTime(token.props.args.speed) || '1.5s';
+ style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
+ break;
+ }
+ case 'jump': {
+ style = this.$store.state.animatedMfm ? 'animation: mfm-jump 0.75s linear infinite;' : '';
+ break;
+ }
+ case 'bounce': {
+ style = this.$store.state.animatedMfm ? 'animation: mfm-bounce 0.75s linear infinite; transform-origin: center bottom;' : '';
+ break;
+ }
+ case 'flip': {
+ const transform =
+ (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
+ token.props.args.v ? 'scaleY(-1)' :
+ 'scaleX(-1)';
+ style = `transform: ${transform};`;
+ break;
+ }
+ case 'x2': {
+ style = `font-size: 200%;`;
+ break;
+ }
+ case 'x3': {
+ style = `font-size: 400%;`;
+ break;
+ }
+ case 'x4': {
+ style = `font-size: 600%;`;
+ break;
+ }
+ case 'font': {
+ const family =
+ token.props.args.serif ? 'serif' :
+ token.props.args.monospace ? 'monospace' :
+ token.props.args.cursive ? 'cursive' :
+ token.props.args.fantasy ? 'fantasy' :
+ token.props.args.emoji ? 'emoji' :
+ token.props.args.math ? 'math' :
+ null;
+ if (family) style = `font-family: ${family};`;
+ break;
+ }
+ case 'blur': {
+ return h('span', {
+ class: '_mfm_blur_',
+ }, genEl(token.children));
+ }
+ case 'rainbow': {
+ style = this.$store.state.animatedMfm ? 'animation: mfm-rainbow 1s linear infinite;' : '';
+ break;
+ }
+ case 'sparkle': {
+ if (!this.$store.state.animatedMfm) {
+ return genEl(token.children);
+ }
+ let count = token.props.args.count ? parseInt(token.props.args.count) : 10;
+ if (count > 100) {
+ count = 100;
+ }
+ const speed = token.props.args.speed ? parseFloat(token.props.args.speed) : 1;
+ return h(MkSparkle, {
+ count, speed,
+ }, genEl(token.children));
+ }
+ }
+ if (style == null) {
+ return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']);
+ } else {
+ return h('span', {
+ style: 'display: inline-block;' + style,
+ }, genEl(token.children));
+ }
+ }
+
+ case 'small': {
+ return [h('small', {
+ style: 'opacity: 0.7;'
+ }, genEl(token.children))];
+ }
+
+ case 'center': {
+ return [h('div', {
+ style: 'text-align:center;'
+ }, genEl(token.children))];
+ }
+
+ case 'url': {
+ return [h(MkUrl, {
+ key: Math.random(),
+ url: token.props.url,
+ rel: 'nofollow noopener',
+ })];
+ }
+
+ case 'link': {
+ return [h(MkLink, {
+ key: Math.random(),
+ url: token.props.url,
+ rel: 'nofollow noopener',
+ }, genEl(token.children))];
+ }
+
+ case 'mention': {
+ return [h(MkMention, {
+ key: Math.random(),
+ host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host,
+ username: token.props.username
+ })];
+ }
+
+ case 'hashtag': {
+ return [h(MkA, {
+ key: Math.random(),
+ to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`,
+ style: 'color:var(--hashtag);'
+ }, `#${token.props.hashtag}`)];
+ }
+
+ case 'blockCode': {
+ return [h(MkCode, {
+ key: Math.random(),
+ code: token.props.code,
+ lang: token.props.lang,
+ })];
+ }
+
+ case 'inlineCode': {
+ return [h(MkCode, {
+ key: Math.random(),
+ code: token.props.code,
+ inline: true
+ })];
+ }
+
+ case 'quote': {
+ if (!this.nowrap) {
+ return [h('div', {
+ class: 'quote'
+ }, genEl(token.children))];
+ } else {
+ return [h('span', {
+ class: 'quote'
+ }, genEl(token.children))];
+ }
+ }
+
+ case 'emojiCode': {
+ return [h(MkEmoji, {
+ key: Math.random(),
+ emoji: `:${token.props.name}:`,
+ customEmojis: this.customEmojis,
+ normal: this.plain
+ })];
+ }
+
+ case 'unicodeEmoji': {
+ return [h(MkEmoji, {
+ key: Math.random(),
+ emoji: token.props.emoji,
+ customEmojis: this.customEmojis,
+ normal: this.plain
+ })];
+ }
+
+ case 'mathInline': {
+ return [h(MkFormula, {
+ key: Math.random(),
+ formula: token.props.formula,
+ block: false
+ })];
+ }
+
+ case 'mathBlock': {
+ return [h(MkFormula, {
+ key: Math.random(),
+ formula: token.props.formula,
+ block: true
+ })];
+ }
+
+ case 'search': {
+ return [h(MkGoogle, {
+ key: Math.random(),
+ q: token.props.query
+ })];
+ }
+
+ default: {
+ console.error('unrecognized ast type:', token.type);
+
+ return [];
+ }
+ }
+ }));
+
+ // Parse ast to DOM
+ return h('span', genEl(ast));
+ }
+});
diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue
new file mode 100644
index 0000000000..2eb9ae8cbe
--- /dev/null
+++ b/packages/client/src/components/mini-chart.vue
@@ -0,0 +1,90 @@
+<template>
+<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible">
+ <defs>
+ <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0">
+ <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop>
+ <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop>
+ </linearGradient>
+ <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+ <polygon
+ :points="polygonPoints"
+ fill="#fff"
+ fill-opacity="0.5"/>
+ <polyline
+ :points="polylinePoints"
+ fill="none"
+ stroke="#fff"
+ stroke-width="2"/>
+ <circle
+ :cx="headX"
+ :cy="headY"
+ r="3"
+ fill="#fff"/>
+ </mask>
+ </defs>
+ <rect
+ x="-10" y="-10"
+ :width="viewBoxX + 20" :height="viewBoxY + 20"
+ :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/>
+</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ src: {
+ type: Array,
+ required: true
+ }
+ },
+ data() {
+ return {
+ viewBoxX: 50,
+ viewBoxY: 30,
+ gradientId: uuid(),
+ maskId: uuid(),
+ polylinePoints: '',
+ polygonPoints: '',
+ headX: null,
+ headY: null,
+ clock: null
+ };
+ },
+ watch: {
+ src() {
+ this.draw();
+ }
+ },
+ created() {
+ this.draw();
+
+ // Vueが何故かWatchを発動させない場合があるので
+ this.clock = setInterval(this.draw, 1000);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ draw() {
+ const stats = this.src.slice().reverse();
+ const peak = Math.max.apply(null, stats) || 1;
+
+ const polylinePoints = stats.map((n, i) => [
+ i * (this.viewBoxX / (stats.length - 1)),
+ (1 - (n / peak)) * this.viewBoxY
+ ]);
+
+ this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+ this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`;
+
+ this.headX = polylinePoints[polylinePoints.length - 1][0];
+ this.headY = polylinePoints[polylinePoints.length - 1][1];
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue
new file mode 100644
index 0000000000..2086736683
--- /dev/null
+++ b/packages/client/src/components/modal-page-window.vue
@@ -0,0 +1,223 @@
+<template>
+<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
+ <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
+ <div class="header" @contextmenu="onContextmenu">
+ <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button>
+ <span v-else style="display: inline-block; width: 20px"></span>
+ <span v-if="pageInfo" class="title">
+ <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i>
+ <span>{{ pageInfo.title }}</span>
+ </span>
+ <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button>
+ </div>
+ <div class="body">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
+ <keep-alive>
+ <component :is="component" v-bind="props" :ref="changePage"/>
+ </keep-alive>
+ </MkStickyContainer>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import { popout } from '@/scripts/popout';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { resolve } from '@/router';
+import { url } from '@/config';
+import * as symbols from '@/symbols';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ inject: {
+ sideViewHook: {
+ default: null
+ }
+ },
+
+ provide() {
+ return {
+ navHook: (path) => {
+ this.navigate(path);
+ },
+ shouldHeaderThin: true,
+ };
+ },
+
+ props: {
+ initialPath: {
+ type: String,
+ required: true,
+ },
+ initialComponent: {
+ type: Object,
+ required: true,
+ },
+ initialProps: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ width: 860,
+ height: 660,
+ pageInfo: null,
+ path: this.initialPath,
+ component: this.initialComponent,
+ props: this.initialProps,
+ history: [],
+ };
+ },
+
+ computed: {
+ url(): string {
+ return url + this.path;
+ },
+
+ contextmenu() {
+ return [{
+ type: 'label',
+ text: this.path,
+ }, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: this.expand
+ }, this.sideViewHook ? {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.sideViewHook(this.path);
+ this.$refs.window.close();
+ }
+ } : undefined, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.popout,
+ action: this.popout
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.url, '_blank');
+ this.$refs.window.close();
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(this.url);
+ }
+ }];
+ },
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ navigate(path, record = true) {
+ if (record) this.history.push(this.path);
+ this.path = path;
+ const { component, props } = resolve(path);
+ this.component = component;
+ this.props = props;
+ },
+
+ back() {
+ this.navigate(this.history.pop(), false);
+ },
+
+ expand() {
+ this.$router.push(this.path);
+ this.$refs.window.close();
+ },
+
+ popout() {
+ popout(this.path, this.$el);
+ this.$refs.window.close();
+ },
+
+ onContextmenu(e) {
+ os.contextMenu(this.contextmenu, e);
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hrmcaedk {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ contain: content;
+
+ --root-margin: 24px;
+
+ @media (max-width: 500px) {
+ --root-margin: 16px;
+ }
+
+ > .header {
+ $height: 52px;
+ $height-narrow: 42px;
+ display: flex;
+ flex-shrink: 0;
+ height: $height;
+ line-height: $height;
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ box-shadow: 0px 1px var(--divider);
+
+ > button {
+ height: $height;
+ width: $height;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+
+ @media (max-width: 500px) {
+ height: $height-narrow;
+ line-height: $height-narrow;
+ padding-left: 16px;
+
+ > button {
+ height: $height-narrow;
+ width: $height-narrow;
+ }
+ }
+
+ > .title {
+ flex: 1;
+
+ > .icon {
+ margin-right: 0.5em;
+ }
+ }
+ }
+
+ > .body {
+ overflow: auto;
+ background: var(--bg);
+ }
+}
+</style>
diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
new file mode 100644
index 0000000000..8b6905a0e4
--- /dev/null
+++ b/packages/client/src/components/note-detailed.vue
@@ -0,0 +1,1229 @@
+<template>
+<div
+ class="lxwezrsl _block"
+ v-if="!muted"
+ v-show="!isDeleted"
+ :tabindex="!isDeleted ? '-1' : null"
+ :class="{ renote: isRenote }"
+ v-hotkey="keymap"
+ v-size="{ max: [500, 450, 350, 300] }"
+>
+ <XSub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/>
+ <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
+ <div class="renote" v-if="isRenote">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <i class="fas fa-retweet"></i>
+ <I18n :src="$ts.renotedBy" tag="span">
+ <template #user>
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ </template>
+ </I18n>
+ <div class="info">
+ <button class="_button time" @click="showRenoteMenu()" ref="renoteTime">
+ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
+ <MkTime :time="note.createdAt"/>
+ </button>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+ </div>
+ <article class="article" @contextmenu.stop="onContextmenu">
+ <header class="header">
+ <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
+ <div class="body">
+ <div class="top">
+ <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.user.id">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ <span class="is-bot" v-if="appearNote.user.isBot">bot</span>
+ <span class="admin" v-if="appearNote.user.isAdmin"><i class="fas fa-bookmark"></i></span>
+ <span class="moderator" v-if="!appearNote.user.isAdmin && appearNote.user.isModerator"><i class="far fa-bookmark"></i></span>
+ <span class="visibility" v-if="appearNote.visibility !== 'public'">
+ <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="appearNote.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+ <div class="username"><MkAcct :user="appearNote.user"/></div>
+ <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+ </div>
+ </header>
+ <div class="main">
+ <div class="body">
+ <p v-if="appearNote.cw != null" class="cw">
+ <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <XCwButton 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">({{ $ts.private }})</span>
+ <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <a class="rp" v-if="appearNote.renote != null">RN:</a>
+ <div class="translation" v-if="translating || translation">
+ <MkLoading v-if="translating" mini/>
+ <div class="translated" v-else>
+ <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
+ {{ translation.text }}
+ </div>
+ </div>
+ </div>
+ <div class="files" v-if="appearNote.files.length > 0">
+ <XMediaList :media-list="appearNote.files"/>
+ </div>
+ <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="true" class="url-preview"/>
+ <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div>
+ </div>
+ <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
+ </div>
+ <footer class="footer">
+ <div class="info">
+ <span class="mobile" v-if="appearNote.viaMobile"><i class="fas fa-mobile-alt"></i></span>
+ <MkTime class="created-at" :time="appearNote.createdAt" mode="detail"/>
+ </div>
+ <XReactionsViewer :note="appearNote" ref="reactionsViewer"/>
+ <button @click="reply()" class="button _button">
+ <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template>
+ <template v-else><i class="fas fa-reply"></i></template>
+ <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
+ </button>
+ <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
+ <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
+ </button>
+ <button v-else class="button _button">
+ <i class="fas fa-ban"></i>
+ </button>
+ <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
+ <i class="fas fa-plus"></i>
+ </button>
+ <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
+ <i class="fas fa-minus"></i>
+ </button>
+ <button class="button _button" @click="menu()" ref="menuButton">
+ <i class="fas fa-ellipsis-h"></i>
+ </button>
+ </footer>
+ </div>
+ </article>
+ <XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
+</div>
+<div v-else class="_panel muted" @click="muted = false">
+ <I18n :src="$ts.userSaysSomething" tag="small">
+ <template #name>
+ <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ </template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+import * as mfm from 'mfm-js';
+import { sum } from '@/scripts/array';
+import XSub from './note.sub.vue';
+import XNoteHeader from './note-header.vue';
+import XNoteSimple from './note-simple.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 { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+// TODO: note.vueとほぼ同じなので共通化したい
+export default defineComponent({
+ components: {
+ XSub,
+ XNoteHeader,
+ XNoteSimple,
+ XReactionsViewer,
+ XMediaList,
+ XCwButton,
+ XPoll,
+ MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+ MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+ },
+
+ inject: {
+ inChannel: {
+ default: null
+ },
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ },
+
+ emits: ['update:note'],
+
+ data() {
+ return {
+ connection: null,
+ conversation: [],
+ replies: [],
+ showContent: false,
+ isDeleted: false,
+ muted: false,
+ translation: null,
+ translating: false,
+ };
+ },
+
+ computed: {
+ rs() {
+ return this.$store.state.reactions;
+ },
+ 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.rs[0]),
+ '2': () => this.reactDirectly(this.rs[1]),
+ '3': () => this.reactDirectly(this.rs[2]),
+ '4': () => this.reactDirectly(this.rs[3]),
+ '5': () => this.reactDirectly(this.rs[4]),
+ '6': () => this.reactDirectly(this.rs[5]),
+ '7': () => this.reactDirectly(this.rs[6]),
+ '8': () => this.reactDirectly(this.rs[7]),
+ '9': () => this.reactDirectly(this.rs[8]),
+ '0': () => this.reactDirectly(this.rs[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.$i && (this.$i.id === this.appearNote.userId);
+ },
+
+ isMyRenote(): boolean {
+ return this.$i && (this.$i.id === this.note.userId);
+ },
+
+ canRenote(): boolean {
+ return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
+ },
+
+ reactionsCount(): number {
+ return this.appearNote.reactions
+ ? sum(Object.values(this.appearNote.reactions))
+ : 0;
+ },
+
+ urls(): string[] {
+ if (this.appearNote.text) {
+ return extractUrlFromMfm(mfm.parse(this.appearNote.text));
+ } else {
+ return null;
+ }
+ },
+
+ showTicker() {
+ if (this.$store.state.instanceTicker === 'always') return true;
+ if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
+ return false;
+ }
+ },
+
+ async created() {
+ if (this.$i) {
+ this.connection = os.stream;
+ }
+
+ this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
+
+ // plugin
+ if (noteViewInterruptors.length > 0) {
+ let result = this.note;
+ for (const interruptor of noteViewInterruptors) {
+ result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
+ }
+ this.$emit('update:note', Object.freeze(result));
+ }
+
+ os.api('notes/children', {
+ noteId: this.appearNote.id,
+ limit: 30
+ }).then(replies => {
+ this.replies = replies;
+ });
+
+ if (this.appearNote.replyId) {
+ os.api('notes/conversation', {
+ noteId: this.appearNote.replyId
+ }).then(conversation => {
+ this.conversation = conversation.reverse();
+ });
+ }
+ },
+
+ mounted() {
+ this.capture(true);
+
+ if (this.$i) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+ },
+
+ beforeUnmount() {
+ this.decapture(true);
+
+ if (this.$i) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ }
+ },
+
+ methods: {
+ updateAppearNote(v) {
+ this.$emit('update:note', Object.freeze(this.isRenote ? {
+ ...this.note,
+ renote: {
+ ...this.note.renote,
+ ...v
+ }
+ } : {
+ ...this.note,
+ ...v
+ }));
+ },
+
+ readPromo() {
+ os.api('promo/read', {
+ noteId: this.appearNote.id
+ });
+ this.isDeleted = true;
+ },
+
+ capture(withHandler = false) {
+ if (this.$i) {
+ // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+ this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
+ if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ decapture(withHandler = false) {
+ if (this.$i) {
+ 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;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ if (body.emoji) {
+ const emojis = this.appearNote.emojis || [];
+ if (!emojis.includes(body.emoji)) {
+ n.emojis = [...emojis, body.emoji];
+ }
+ }
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Increment the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: currentCount + 1
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = reaction;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'unreacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Decrement the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: Math.max(0, currentCount - 1)
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = null;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'pollVoted': {
+ const choice = body.choice;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ const choices = [...this.appearNote.poll.choices];
+ choices[choice] = {
+ ...choices[choice],
+ votes: choices[choice].votes + 1,
+ ...(body.userId === this.$i.id ? {
+ isVoted: true
+ } : {})
+ };
+
+ n.poll = {
+ ...this.appearNote.poll,
+ choices: choices
+ };
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'deleted': {
+ this.isDeleted = true;
+ break;
+ }
+ }
+ },
+
+ reply(viaKeyboard = false) {
+ pleaseLogin();
+ os.post({
+ reply: this.appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ this.focus();
+ });
+ },
+
+ renote(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ os.popupMenu([{
+ text: this.$ts.renote,
+ icon: 'fas fa-retweet',
+ action: () => {
+ os.api('notes/create', {
+ renoteId: this.appearNote.id
+ });
+ }
+ }, {
+ text: this.$ts.quote,
+ icon: 'fas fa-quote-right',
+ action: () => {
+ os.post({
+ renote: this.appearNote,
+ });
+ }
+ }], this.$refs.renoteButton, {
+ viaKeyboard
+ });
+ },
+
+ renoteDirectly() {
+ os.apiWithDialog('notes/create', {
+ renoteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.renoted,
+ });
+ }, (e: Error) => {
+ if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantRenote,
+ });
+ } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantReRenote,
+ });
+ }
+ });
+ },
+
+ react(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ reactionPicker.show(this.$refs.reactButton, reaction => {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ }, () => {
+ this.focus();
+ });
+ },
+
+ reactDirectly(reaction) {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ },
+
+ undoReact(note) {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ os.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+ },
+
+ favorite() {
+ pleaseLogin();
+ os.apiWithDialog('notes/favorites/create', {
+ noteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.favorited,
+ });
+ }, (e: Error) => {
+ if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.alreadyFavorited,
+ });
+ } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantFavorite,
+ });
+ }
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.noteDeleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+ });
+ },
+
+ delEdit() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteAndEditConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+
+ os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
+ });
+ },
+
+ toggleFavorite(favorite: boolean) {
+ os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleWatch(watch: boolean) {
+ os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleThreadMute(mute: boolean) {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ getMenu() {
+ let menu;
+ if (this.$i) {
+ const statePromise = os.api('notes/state', {
+ noteId: this.appearNote.id
+ });
+
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined,
+ {
+ icon: 'fas fa-share-alt',
+ text: this.$ts.share,
+ action: this.share
+ },
+ this.$instance.translatorAvailable ? {
+ icon: 'fas fa-language',
+ text: this.$ts.translate,
+ action: this.translate
+ } : undefined,
+ null,
+ statePromise.then(state => state.isFavorited ? {
+ icon: 'fas fa-star',
+ text: this.$ts.unfavorite,
+ action: () => this.toggleFavorite(false)
+ } : {
+ icon: 'fas fa-star',
+ text: this.$ts.favorite,
+ action: () => this.toggleFavorite(true)
+ }),
+ {
+ icon: 'fas fa-paperclip',
+ text: this.$ts.clip,
+ action: () => this.clip()
+ },
+ (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
+ icon: 'fas fa-eye-slash',
+ text: this.$ts.unwatch,
+ action: () => this.toggleWatch(false)
+ } : {
+ icon: 'fas fa-eye',
+ text: this.$ts.watch,
+ action: () => this.toggleWatch(true)
+ }) : undefined,
+ statePromise.then(state => state.isMutedThread ? {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.unmuteThread,
+ action: () => this.toggleThreadMute(false)
+ } : {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.muteThread,
+ action: () => this.toggleThreadMute(true)
+ }),
+ this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.unpin,
+ action: () => this.togglePin(false)
+ } : {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.pin,
+ action: () => this.togglePin(true)
+ } : undefined,
+ ...(this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ {
+ icon: 'fas fa-bullhorn',
+ text: this.$ts.promote,
+ action: this.promote
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId != this.$i.id ? [
+ null,
+ {
+ icon: 'fas fa-exclamation-circle',
+ text: this.$ts.reportAbuse,
+ action: () => {
+ const u = `${url}/notes/${this.appearNote.id}`;
+ os.popup(import('@/components/abuse-report-window.vue'), {
+ user: this.appearNote.user,
+ initialComment: `Note: ${u}\n-----\n`
+ }, {}, 'closed');
+ }
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ this.appearNote.userId == this.$i.id ? {
+ icon: 'fas fa-edit',
+ text: this.$ts.deleteAndEdit,
+ action: this.delEdit
+ } : undefined,
+ {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.delete,
+ danger: true,
+ action: this.del
+ }]
+ : []
+ )]
+ .filter(x => x !== undefined);
+ } else {
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined]
+ .filter(x => x !== undefined);
+ }
+
+ if (noteActions.length > 0) {
+ menu = menu.concat([null, ...noteActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(this.appearNote);
+ }
+ }))]);
+ }
+
+ return menu;
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (window.getSelection().toString() !== '') return;
+
+ if (this.$store.state.useReactionPickerForContextMenu) {
+ e.preventDefault();
+ this.react();
+ } else {
+ os.contextMenu(this.getMenu(), e).then(this.focus);
+ }
+ },
+
+ menu(viaKeyboard = false) {
+ os.popupMenu(this.getMenu(), this.$refs.menuButton, {
+ viaKeyboard
+ }).then(this.focus);
+ },
+
+ showRenoteMenu(viaKeyboard = false) {
+ if (!this.isMyRenote) return;
+ os.popupMenu([{
+ text: this.$ts.unrenote,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: () => {
+ os.api('notes/delete', {
+ noteId: this.note.id
+ });
+ this.isDeleted = true;
+ }
+ }], this.$refs.renoteTime, {
+ viaKeyboard: viaKeyboard
+ });
+ },
+
+ toggleShowContent() {
+ this.showContent = !this.showContent;
+ },
+
+ copyContent() {
+ copyToClipboard(this.appearNote.text);
+ os.success();
+ },
+
+ copyLink() {
+ copyToClipboard(`${url}/notes/${this.appearNote.id}`);
+ os.success();
+ },
+
+ togglePin(pin: boolean) {
+ os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+ noteId: this.appearNote.id
+ }, undefined, null, e => {
+ if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.pinLimitExceeded
+ });
+ }
+ });
+ },
+
+ async clip() {
+ const clips = await os.api('clips/list');
+ os.popupMenu([{
+ icon: 'fas fa-plus',
+ text: this.$ts.createNew,
+ action: async () => {
+ const { canceled, result } = await os.form(this.$ts.createNewClip, {
+ name: {
+ type: 'string',
+ label: this.$ts.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ const clip = await os.apiWithDialog('clips/create', result);
+
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }, null, ...clips.map(clip => ({
+ text: clip.name,
+ action: () => {
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }))], this.$refs.menuButton, {
+ }).then(this.focus);
+ },
+
+ async promote() {
+ const { canceled, result: days } = await os.dialog({
+ title: this.$ts.numberOfDays,
+ input: { type: 'number' }
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('admin/promo/create', {
+ noteId: this.appearNote.id,
+ expiresAt: Date.now() + (86400000 * days)
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.$t('noteOf', { user: this.appearNote.user.name }),
+ text: this.appearNote.text,
+ url: `${url}/notes/${this.appearNote.id}`
+ });
+ },
+
+ async translate() {
+ if (this.translation != null) return;
+ this.translating = true;
+ const res = await os.api('notes/translate', {
+ noteId: this.appearNote.id,
+ targetLang: localStorage.getItem('lang') || navigator.language,
+ });
+ this.translating = false;
+ this.translation = res;
+ },
+
+ focus() {
+ this.$el.focus();
+ },
+
+ blur() {
+ this.$el.blur();
+ },
+
+ focusBefore() {
+ focusPrev(this.$el);
+ },
+
+ focusAfter() {
+ focusNext(this.$el);
+ },
+
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lxwezrsl {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+ overflow: hidden;
+ contain: content;
+
+ &:focus-visible {
+ outline: none;
+
+ &:after {
+ content: "";
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ z-index: 10;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: calc(100% - 8px);
+ height: calc(100% - 8px);
+ border: dashed 1px var(--focus);
+ border-radius: var(--radius);
+ box-sizing: border-box;
+ }
+ }
+
+ &:hover > .article > .main > .footer > .button {
+ opacity: 1;
+ }
+
+ > .reply-to {
+ opacity: 0.7;
+ padding-bottom: 0;
+ }
+
+ > .reply-to-more {
+ opacity: 0.7;
+ }
+
+ > .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;
+ }
+
+ > i {
+ 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;
+
+ > .time {
+ flex-shrink: 0;
+ color: inherit;
+
+ > .dropdownIcon {
+ margin-right: 4px;
+ }
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > .renote + .article {
+ padding-top: 8px;
+ }
+
+ > .article {
+ padding: 32px;
+ font-size: 1.1em;
+
+ > .header {
+ display: flex;
+ position: relative;
+ margin-bottom: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ width: 58px;
+ height: 58px;
+ }
+
+ > .body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 16px;
+ font-size: 0.95em;
+
+ > .top {
+ > .name {
+ font-weight: bold;
+ }
+
+ > .is-bot {
+ flex-shrink: 0;
+ align-self: center;
+ margin: 0 0.5em;
+ padding: 4px 6px;
+ font-size: 80%;
+ border: solid 0.5px var(--divider);
+ border-radius: 4px;
+ }
+
+ > .admin,
+ > .moderator {
+ margin-right: 0.5em;
+ color: var(--badge);
+ }
+ }
+ }
+ }
+
+ > .main {
+ > .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);
+ }
+
+ > .translation {
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-top: 8px;
+ }
+ }
+
+ > .url-preview {
+ margin-top: 8px;
+ }
+
+ > .poll {
+ font-size: 80%;
+ }
+
+ > .renote {
+ padding: 8px 0;
+
+ > * {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ }
+ }
+ }
+
+ > .channel {
+ opacity: 0.7;
+ font-size: 80%;
+ }
+ }
+
+ > .footer {
+ > .info {
+ margin: 16px 0;
+ opacity: 0.7;
+ font-size: 0.9em;
+ }
+
+ > .button {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+
+ > .count {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+ }
+
+ &.reacted {
+ color: var(--accent);
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-top: solid 0.5px var(--divider);
+ }
+
+ &.max-width_500px {
+ font-size: 0.9em;
+ }
+
+ &.max-width_450px {
+ > .renote {
+ padding: 8px 16px 0 16px;
+ }
+
+ > .article {
+ padding: 16px;
+
+ > .header {
+ > .avatar {
+ 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 {
+ > .header {
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+ }
+
+ > .main {
+ > .footer {
+ > .button {
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+.muted {
+ padding: 8px;
+ text-align: center;
+ opacity: 0.7;
+}
+</style>
diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue
new file mode 100644
index 0000000000..c61ec41dd1
--- /dev/null
+++ b/packages/client/src/components/note-header.vue
@@ -0,0 +1,115 @@
+<template>
+<header class="kkwtjztg">
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ <div class="is-bot" v-if="note.user.isBot">bot</div>
+ <div class="username"><MkAcct :user="note.user"/></div>
+ <div class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></div>
+ <div class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></div>
+ <div class="info">
+ <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span>
+ <MkA class="created-at" :to="notePage(note)">
+ <MkTime :time="note.createdAt"/>
+ </MkA>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+</header>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import notePage from '@/filters/note';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ notePage,
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kkwtjztg {
+ display: flex;
+ align-items: baseline;
+ white-space: nowrap;
+
+ > .name {
+ flex-shrink: 1;
+ display: block;
+ margin: 0 .5em 0 0;
+ padding: 0;
+ overflow: hidden;
+ 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%;
+ border: solid 0.5px var(--divider);
+ border-radius: 3px;
+ }
+
+ > .admin,
+ > .moderator {
+ flex-shrink: 0;
+ margin-right: 0.5em;
+ color: var(--badge);
+ }
+
+ > .username {
+ flex-shrink: 9999999;
+ margin: 0 .5em 0 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .info {
+ flex-shrink: 0;
+ margin-left: auto;
+ font-size: 0.9em;
+
+ > .mobile {
+ margin-right: 8px;
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue
new file mode 100644
index 0000000000..a474a01341
--- /dev/null
+++ b/packages/client/src/components/note-preview.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="fefdfafb" v-size="{ min: [350, 500] }">
+ <MkAvatar class="avatar" :user="$i"/>
+ <div class="main">
+ <div class="header">
+ <MkUserName :user="$i"/>
+ </div>
+ <div class="body">
+ <div class="content">
+ <Mfm :text="text" :author="$i" :i="$i"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ components: {
+ },
+
+ props: {
+ text: {
+ type: String,
+ required: true
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fefdfafb {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ overflow: clip;
+ font-size: 0.95em;
+
+ &.min-width_350px {
+ > .avatar {
+ margin: 0 10px 0 0;
+ width: 44px;
+ height: 44px;
+ }
+ }
+
+ &.min-width_500px {
+ > .avatar {
+ 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;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ cursor: default;
+ margin: 0;
+ padding: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue
new file mode 100644
index 0000000000..2f19bd6e0b
--- /dev/null
+++ b/packages/client/src/components/note-simple.vue
@@ -0,0 +1,113 @@
+<template>
+<div class="yohlumlk" v-size="{ min: [350, 500] }">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <div class="main">
+ <XNoteHeader 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>
+ <XCwButton v-model="showContent" :note="note"/>
+ </p>
+ <div class="content" v-show="note.cw == null || showContent">
+ <XSubNote-content class="text" :note="note"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from './cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XNoteHeader,
+ XSubNoteContent,
+ XCwButton,
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ showContent: false
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yohlumlk {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ overflow: clip;
+ font-size: 0.95em;
+
+ &.min-width_350px {
+ > .avatar {
+ margin: 0 10px 0 0;
+ width: 44px;
+ height: 44px;
+ }
+ }
+
+ &.min-width_500px {
+ > .avatar {
+ 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;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ cursor: default;
+ margin: 0;
+ padding: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/note.sub.vue b/packages/client/src/components/note.sub.vue
new file mode 100644
index 0000000000..45204854be
--- /dev/null
+++ b/packages/client/src/components/note.sub.vue
@@ -0,0 +1,146 @@
+<template>
+<div class="wrpstxzv" :class="{ children }" v-size="{ max: [450] }">
+ <div class="main">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <div class="body">
+ <XNoteHeader 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="$i" :custom-emojis="note.emojis" />
+ <XCwButton v-model="showContent" :note="note"/>
+ </p>
+ <div class="content" v-show="note.cw == null || showContent">
+ <XSubNote-content class="text" :note="note"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from './cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ name: 'XSub',
+
+ components: {
+ XNoteHeader,
+ XSubNoteContent,
+ XCwButton,
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ detail: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ children: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ // TODO
+ truncate: {
+ type: Boolean,
+ default: true
+ }
+ },
+
+ data() {
+ return {
+ showContent: false,
+ replies: [],
+ };
+ },
+
+ created() {
+ if (this.detail) {
+ os.api('notes/children', {
+ noteId: this.note.id,
+ limit: 5
+ }).then(replies => {
+ this.replies = replies;
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.wrpstxzv {
+ padding: 16px 32px;
+ font-size: 0.9em;
+
+ &.max-width_450px {
+ padding: 14px 16px;
+ }
+
+ &.children {
+ padding: 10px 0 0 16px;
+ font-size: 1em;
+
+ &.max-width_450px {
+ padding: 10px 0 0 8px;
+ }
+ }
+
+ > .main {
+ display: flex;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 8px 0 0;
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ 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;
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-left: solid 0.5px var(--divider);
+ margin-top: 10px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
new file mode 100644
index 0000000000..b1ec674b67
--- /dev/null
+++ b/packages/client/src/components/note.vue
@@ -0,0 +1,1228 @@
+<template>
+<div
+ class="tkcbzcuz"
+ v-if="!muted"
+ v-show="!isDeleted"
+ :tabindex="!isDeleted ? '-1' : null"
+ :class="{ renote: isRenote }"
+ v-hotkey="keymap"
+ v-size="{ max: [500, 450, 350, 300] }"
+>
+ <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
+ <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div>
+ <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div>
+ <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div>
+ <div class="renote" v-if="isRenote">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <i class="fas fa-retweet"></i>
+ <I18n :src="$ts.renotedBy" tag="span">
+ <template #user>
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ </template>
+ </I18n>
+ <div class="info">
+ <button class="_button time" @click="showRenoteMenu()" ref="renoteTime">
+ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
+ <MkTime :time="note.createdAt"/>
+ </button>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+ </div>
+ <article class="article" @contextmenu.stop="onContextmenu">
+ <MkAvatar class="avatar" :user="appearNote.user"/>
+ <div class="main">
+ <XNoteHeader class="header" :note="appearNote" :mini="true"/>
+ <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+ <div class="body">
+ <p v-if="appearNote.cw != null" class="cw">
+ <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <XCwButton v-model="showContent" :note="appearNote"/>
+ </p>
+ <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent">
+ <div class="text">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+ <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <a class="rp" v-if="appearNote.renote != null">RN:</a>
+ <div class="translation" v-if="translating || translation">
+ <MkLoading v-if="translating" mini/>
+ <div class="translated" v-else>
+ <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
+ {{ translation.text }}
+ </div>
+ </div>
+ </div>
+ <div class="files" v-if="appearNote.files.length > 0">
+ <XMediaList :media-list="appearNote.files"/>
+ </div>
+ <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/>
+ <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div>
+ <button v-if="collapsed" class="fade _button" @click="collapsed = false">
+ <span>{{ $ts.showMore }}</span>
+ </button>
+ </div>
+ <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
+ </div>
+ <footer class="footer">
+ <XReactionsViewer :note="appearNote" ref="reactionsViewer"/>
+ <button @click="reply()" class="button _button">
+ <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template>
+ <template v-else><i class="fas fa-reply"></i></template>
+ <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
+ </button>
+ <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
+ <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
+ </button>
+ <button v-else class="button _button">
+ <i class="fas fa-ban"></i>
+ </button>
+ <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
+ <i class="fas fa-plus"></i>
+ </button>
+ <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
+ <i class="fas fa-minus"></i>
+ </button>
+ <button class="button _button" @click="menu()" ref="menuButton">
+ <i class="fas fa-ellipsis-h"></i>
+ </button>
+ </footer>
+ </div>
+ </article>
+</div>
+<div v-else class="muted" @click="muted = false">
+ <I18n :src="$ts.userSaysSomething" tag="small">
+ <template #name>
+ <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ </template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+import * as mfm from 'mfm-js';
+import { sum } from '@/scripts/array';
+import XSub from './note.sub.vue';
+import XNoteHeader from './note-header.vue';
+import XNoteSimple from './note-simple.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 { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+export default defineComponent({
+ components: {
+ XSub,
+ XNoteHeader,
+ XNoteSimple,
+ XReactionsViewer,
+ XMediaList,
+ XCwButton,
+ XPoll,
+ MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+ MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+ },
+
+ inject: {
+ inChannel: {
+ default: null
+ },
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ pinned: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['update:note'],
+
+ data() {
+ return {
+ connection: null,
+ replies: [],
+ showContent: false,
+ collapsed: false,
+ isDeleted: false,
+ muted: false,
+ translation: null,
+ translating: false,
+ };
+ },
+
+ computed: {
+ rs() {
+ return this.$store.state.reactions;
+ },
+ 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.rs[0]),
+ '2': () => this.reactDirectly(this.rs[1]),
+ '3': () => this.reactDirectly(this.rs[2]),
+ '4': () => this.reactDirectly(this.rs[3]),
+ '5': () => this.reactDirectly(this.rs[4]),
+ '6': () => this.reactDirectly(this.rs[5]),
+ '7': () => this.reactDirectly(this.rs[6]),
+ '8': () => this.reactDirectly(this.rs[7]),
+ '9': () => this.reactDirectly(this.rs[8]),
+ '0': () => this.reactDirectly(this.rs[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.$i && (this.$i.id === this.appearNote.userId);
+ },
+
+ isMyRenote(): boolean {
+ return this.$i && (this.$i.id === this.note.userId);
+ },
+
+ canRenote(): boolean {
+ return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
+ },
+
+ reactionsCount(): number {
+ return this.appearNote.reactions
+ ? sum(Object.values(this.appearNote.reactions))
+ : 0;
+ },
+
+ urls(): string[] {
+ if (this.appearNote.text) {
+ return extractUrlFromMfm(mfm.parse(this.appearNote.text));
+ } else {
+ return null;
+ }
+ },
+
+ showTicker() {
+ if (this.$store.state.instanceTicker === 'always') return true;
+ if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
+ return false;
+ }
+ },
+
+ async created() {
+ if (this.$i) {
+ this.connection = os.stream;
+ }
+
+ this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
+ (this.appearNote.text.split('\n').length > 9) ||
+ (this.appearNote.text.length > 500)
+ );
+ this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
+
+ // plugin
+ if (noteViewInterruptors.length > 0) {
+ let result = this.note;
+ for (const interruptor of noteViewInterruptors) {
+ result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
+ }
+ this.$emit('update:note', Object.freeze(result));
+ }
+ },
+
+ mounted() {
+ this.capture(true);
+
+ if (this.$i) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+ },
+
+ beforeUnmount() {
+ this.decapture(true);
+
+ if (this.$i) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ }
+ },
+
+ methods: {
+ updateAppearNote(v) {
+ this.$emit('update:note', Object.freeze(this.isRenote ? {
+ ...this.note,
+ renote: {
+ ...this.note.renote,
+ ...v
+ }
+ } : {
+ ...this.note,
+ ...v
+ }));
+ },
+
+ readPromo() {
+ os.api('promo/read', {
+ noteId: this.appearNote.id
+ });
+ this.isDeleted = true;
+ },
+
+ capture(withHandler = false) {
+ if (this.$i) {
+ // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+ this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
+ if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ decapture(withHandler = false) {
+ if (this.$i) {
+ 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;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ if (body.emoji) {
+ const emojis = this.appearNote.emojis || [];
+ if (!emojis.includes(body.emoji)) {
+ n.emojis = [...emojis, body.emoji];
+ }
+ }
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Increment the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: currentCount + 1
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = reaction;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'unreacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Decrement the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: Math.max(0, currentCount - 1)
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = null;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'pollVoted': {
+ const choice = body.choice;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ const choices = [...this.appearNote.poll.choices];
+ choices[choice] = {
+ ...choices[choice],
+ votes: choices[choice].votes + 1,
+ ...(body.userId === this.$i.id ? {
+ isVoted: true
+ } : {})
+ };
+
+ n.poll = {
+ ...this.appearNote.poll,
+ choices: choices
+ };
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'deleted': {
+ this.isDeleted = true;
+ break;
+ }
+ }
+ },
+
+ reply(viaKeyboard = false) {
+ pleaseLogin();
+ os.post({
+ reply: this.appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ this.focus();
+ });
+ },
+
+ renote(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ os.popupMenu([{
+ text: this.$ts.renote,
+ icon: 'fas fa-retweet',
+ action: () => {
+ os.api('notes/create', {
+ renoteId: this.appearNote.id
+ });
+ }
+ }, {
+ text: this.$ts.quote,
+ icon: 'fas fa-quote-right',
+ action: () => {
+ os.post({
+ renote: this.appearNote,
+ });
+ }
+ }], this.$refs.renoteButton, {
+ viaKeyboard
+ });
+ },
+
+ renoteDirectly() {
+ os.apiWithDialog('notes/create', {
+ renoteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.renoted,
+ });
+ }, (e: Error) => {
+ if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantRenote,
+ });
+ } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantReRenote,
+ });
+ }
+ });
+ },
+
+ react(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ reactionPicker.show(this.$refs.reactButton, reaction => {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ }, () => {
+ this.focus();
+ });
+ },
+
+ reactDirectly(reaction) {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ },
+
+ undoReact(note) {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ os.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+ },
+
+ favorite() {
+ pleaseLogin();
+ os.apiWithDialog('notes/favorites/create', {
+ noteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.favorited,
+ });
+ }, (e: Error) => {
+ if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.alreadyFavorited,
+ });
+ } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantFavorite,
+ });
+ }
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.noteDeleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+ });
+ },
+
+ delEdit() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteAndEditConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+
+ os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
+ });
+ },
+
+ toggleFavorite(favorite: boolean) {
+ os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleWatch(watch: boolean) {
+ os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleThreadMute(mute: boolean) {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ getMenu() {
+ let menu;
+ if (this.$i) {
+ const statePromise = os.api('notes/state', {
+ noteId: this.appearNote.id
+ });
+
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined,
+ {
+ icon: 'fas fa-share-alt',
+ text: this.$ts.share,
+ action: this.share
+ },
+ this.$instance.translatorAvailable ? {
+ icon: 'fas fa-language',
+ text: this.$ts.translate,
+ action: this.translate
+ } : undefined,
+ null,
+ statePromise.then(state => state.isFavorited ? {
+ icon: 'fas fa-star',
+ text: this.$ts.unfavorite,
+ action: () => this.toggleFavorite(false)
+ } : {
+ icon: 'fas fa-star',
+ text: this.$ts.favorite,
+ action: () => this.toggleFavorite(true)
+ }),
+ {
+ icon: 'fas fa-paperclip',
+ text: this.$ts.clip,
+ action: () => this.clip()
+ },
+ (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
+ icon: 'fas fa-eye-slash',
+ text: this.$ts.unwatch,
+ action: () => this.toggleWatch(false)
+ } : {
+ icon: 'fas fa-eye',
+ text: this.$ts.watch,
+ action: () => this.toggleWatch(true)
+ }) : undefined,
+ statePromise.then(state => state.isMutedThread ? {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.unmuteThread,
+ action: () => this.toggleThreadMute(false)
+ } : {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.muteThread,
+ action: () => this.toggleThreadMute(true)
+ }),
+ this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.unpin,
+ action: () => this.togglePin(false)
+ } : {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.pin,
+ action: () => this.togglePin(true)
+ } : undefined,
+ ...(this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ {
+ icon: 'fas fa-bullhorn',
+ text: this.$ts.promote,
+ action: this.promote
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId != this.$i.id ? [
+ null,
+ {
+ icon: 'fas fa-exclamation-circle',
+ text: this.$ts.reportAbuse,
+ action: () => {
+ const u = `${url}/notes/${this.appearNote.id}`;
+ os.popup(import('@/components/abuse-report-window.vue'), {
+ user: this.appearNote.user,
+ initialComment: `Note: ${u}\n-----\n`
+ }, {}, 'closed');
+ }
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ this.appearNote.userId == this.$i.id ? {
+ icon: 'fas fa-edit',
+ text: this.$ts.deleteAndEdit,
+ action: this.delEdit
+ } : undefined,
+ {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.delete,
+ danger: true,
+ action: this.del
+ }]
+ : []
+ )]
+ .filter(x => x !== undefined);
+ } else {
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined]
+ .filter(x => x !== undefined);
+ }
+
+ if (noteActions.length > 0) {
+ menu = menu.concat([null, ...noteActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(this.appearNote);
+ }
+ }))]);
+ }
+
+ return menu;
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (window.getSelection().toString() !== '') return;
+
+ if (this.$store.state.useReactionPickerForContextMenu) {
+ e.preventDefault();
+ this.react();
+ } else {
+ os.contextMenu(this.getMenu(), e).then(this.focus);
+ }
+ },
+
+ menu(viaKeyboard = false) {
+ os.popupMenu(this.getMenu(), this.$refs.menuButton, {
+ viaKeyboard
+ }).then(this.focus);
+ },
+
+ showRenoteMenu(viaKeyboard = false) {
+ if (!this.isMyRenote) return;
+ os.popupMenu([{
+ text: this.$ts.unrenote,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: () => {
+ os.api('notes/delete', {
+ noteId: this.note.id
+ });
+ this.isDeleted = true;
+ }
+ }], this.$refs.renoteTime, {
+ viaKeyboard: viaKeyboard
+ });
+ },
+
+ toggleShowContent() {
+ this.showContent = !this.showContent;
+ },
+
+ copyContent() {
+ copyToClipboard(this.appearNote.text);
+ os.success();
+ },
+
+ copyLink() {
+ copyToClipboard(`${url}/notes/${this.appearNote.id}`);
+ os.success();
+ },
+
+ togglePin(pin: boolean) {
+ os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+ noteId: this.appearNote.id
+ }, undefined, null, e => {
+ if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.pinLimitExceeded
+ });
+ }
+ });
+ },
+
+ async clip() {
+ const clips = await os.api('clips/list');
+ os.popupMenu([{
+ icon: 'fas fa-plus',
+ text: this.$ts.createNew,
+ action: async () => {
+ const { canceled, result } = await os.form(this.$ts.createNewClip, {
+ name: {
+ type: 'string',
+ label: this.$ts.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ const clip = await os.apiWithDialog('clips/create', result);
+
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }, null, ...clips.map(clip => ({
+ text: clip.name,
+ action: () => {
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }))], this.$refs.menuButton, {
+ }).then(this.focus);
+ },
+
+ async promote() {
+ const { canceled, result: days } = await os.dialog({
+ title: this.$ts.numberOfDays,
+ input: { type: 'number' }
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('admin/promo/create', {
+ noteId: this.appearNote.id,
+ expiresAt: Date.now() + (86400000 * days)
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.$t('noteOf', { user: this.appearNote.user.name }),
+ text: this.appearNote.text,
+ url: `${url}/notes/${this.appearNote.id}`
+ });
+ },
+
+ async translate() {
+ if (this.translation != null) return;
+ this.translating = true;
+ const res = await os.api('notes/translate', {
+ noteId: this.appearNote.id,
+ targetLang: localStorage.getItem('lang') || navigator.language,
+ });
+ this.translating = false;
+ this.translation = res;
+ },
+
+ focus() {
+ this.$el.focus();
+ },
+
+ blur() {
+ this.$el.blur();
+ },
+
+ focusBefore() {
+ focusPrev(this.$el);
+ },
+
+ focusAfter() {
+ focusNext(this.$el);
+ },
+
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tkcbzcuz {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+ overflow: clip;
+ contain: content;
+
+ // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
+ // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
+ // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
+ // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
+ // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
+ //content-visibility: auto;
+ //contain-intrinsic-size: 0 128px;
+
+ &:focus-visible {
+ outline: none;
+
+ &:after {
+ content: "";
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ z-index: 10;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: calc(100% - 8px);
+ height: calc(100% - 8px);
+ border: dashed 1px var(--focus);
+ border-radius: var(--radius);
+ box-sizing: border-box;
+ }
+ }
+
+ &:hover > .article > .main > .footer > .button {
+ opacity: 1;
+ }
+
+ > .info {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 24px;
+ font-size: 90%;
+ white-space: pre;
+ color: #d28a3f;
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > .hide {
+ margin-left: auto;
+ color: inherit;
+ }
+ }
+
+ > .info + .article {
+ padding-top: 8px;
+ }
+
+ > .reply-to {
+ opacity: 0.7;
+ padding-bottom: 0;
+ }
+
+ > .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;
+ }
+
+ > i {
+ 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;
+
+ > .time {
+ flex-shrink: 0;
+ color: inherit;
+
+ > .dropdownIcon {
+ margin-right: 4px;
+ }
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > .renote + .article {
+ padding-top: 8px;
+ }
+
+ > .article {
+ display: flex;
+ padding: 28px 32px 18px;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 14px 8px 0;
+ width: 58px;
+ height: 58px;
+ position: sticky;
+ top: calc(22px + var(--stickyTop, 0px));
+ left: 0;
+ }
+
+ > .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 {
+ &.collapsed {
+ position: relative;
+ max-height: 9em;
+ overflow: hidden;
+
+ > .fade {
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+
+ > span {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+ }
+
+ &:hover {
+ > span {
+ background: var(--panelHighlight);
+ }
+ }
+ }
+ }
+
+ > .text {
+ overflow-wrap: break-word;
+
+ > .reply {
+ color: var(--accent);
+ margin-right: 0.5em;
+ }
+
+ > .rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+ }
+
+ > .translation {
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-top: 8px;
+ }
+ }
+
+ > .url-preview {
+ margin-top: 8px;
+ }
+
+ > .poll {
+ font-size: 80%;
+ }
+
+ > .renote {
+ padding: 8px 0;
+
+ > * {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ }
+ }
+ }
+
+ > .channel {
+ opacity: 0.7;
+ font-size: 80%;
+ }
+ }
+
+ > .footer {
+ > .button {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+
+ > .count {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+ }
+
+ &.reacted {
+ color: var(--accent);
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-top: solid 0.5px var(--divider);
+ }
+
+ &.max-width_500px {
+ font-size: 0.9em;
+ }
+
+ &.max-width_450px {
+ > .renote {
+ padding: 8px 16px 0 16px;
+ }
+
+ > .info {
+ padding: 8px 16px 0 16px;
+ }
+
+ > .article {
+ padding: 14px 16px 9px;
+
+ > .avatar {
+ margin: 0 10px 8px 0;
+ width: 50px;
+ height: 50px;
+ top: calc(14px + var(--stickyTop, 0px));
+ }
+ }
+ }
+
+ &.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;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+.muted {
+ padding: 8px;
+ text-align: center;
+ opacity: 0.7;
+}
+</style>
diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue
new file mode 100644
index 0000000000..1e7da7a2b0
--- /dev/null
+++ b/packages/client/src/components/notes.vue
@@ -0,0 +1,130 @@
+<template>
+<transition name="fade" mode="out-in">
+ <MkLoading v-if="fetching"/>
+
+ <MkError v-else-if="error" @retry="init()"/>
+
+ <div class="_fullinfo" v-else-if="empty">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noNotes }}</div>
+ </div>
+
+ <div v-else class="giivymft" :class="{ noGap }">
+ <div v-show="more && reversed" style="margin-bottom: var(--margin);">
+ <MkButton style="margin: 0 auto;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+
+ <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes">
+ <XNote class="qtqtichx" :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
+ </XList>
+
+ <div v-show="more && !reversed" style="margin-top: var(--margin);">
+ <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import XNote from './note.vue';
+import XList from './date-separated-list.vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ XNote, XList, MkButton,
+ },
+
+ mixins: [
+ paging({
+ before: (self) => {
+ self.$emit('before');
+ },
+
+ after: (self, e) => {
+ self.$emit('after', e);
+ }
+ }),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+ prop: {
+ type: String,
+ required: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['before', 'after'],
+
+ computed: {
+ notes(): any[] {
+ return this.prop ? this.items.map(item => item[this.prop]) : this.items;
+ },
+
+ reversed(): boolean {
+ return this.pagination.reversed;
+ }
+ },
+
+ methods: {
+ updated(oldValue, newValue) {
+ const i = this.notes.findIndex(n => n === oldValue);
+ if (this.prop) {
+ this.items[i][this.prop] = newValue;
+ } else {
+ this.items[i] = newValue;
+ }
+ },
+
+ focus() {
+ this.$refs.notes.focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.giivymft {
+ &.noGap {
+ > .notes {
+ background: var(--panel);
+ }
+ }
+
+ &:not(.noGap) {
+ > .notes {
+ background: var(--bg);
+
+ .qtqtichx {
+ background: var(--panel);
+ border-radius: var(--radius);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue
new file mode 100644
index 0000000000..ec1efec261
--- /dev/null
+++ b/packages/client/src/components/notification-setting-window.vue
@@ -0,0 +1,99 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="400"
+ :height="450"
+ :with-ok-button="true"
+ :ok-button-disabled="false"
+ @ok="ok()"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.notificationSetting }}</template>
+ <div class="_monolithic_">
+ <div v-if="showGlobalToggle" class="_section">
+ <MkSwitch v-model="useGlobalSetting">
+ {{ $ts.useGlobalSetting }}
+ <template #caption>{{ $ts.useGlobalSettingDesc }}</template>
+ </MkSwitch>
+ </div>
+ <div v-if="!useGlobalSetting" class="_section">
+ <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo>
+ <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton>
+ <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton>
+ <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkSwitch from './form/switch.vue';
+import MkInfo from './ui/info.vue';
+import MkButton from './ui/button.vue';
+import { notificationTypes } from 'misskey-js';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkSwitch,
+ MkInfo,
+ MkButton
+ },
+
+ props: {
+ includingTypes: {
+ // TODO: これで型に合わないものを弾いてくれるのかどうか要調査
+ type: Array as PropType<typeof notificationTypes[number][]>,
+ required: false,
+ default: null,
+ },
+ showGlobalToggle: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ typesMap: {} as Record<typeof notificationTypes[number], boolean>,
+ useGlobalSetting: false,
+ notificationTypes,
+ };
+ },
+
+ created() {
+ this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle;
+
+ for (const type of this.notificationTypes) {
+ this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type);
+ }
+ },
+
+ methods: {
+ ok() {
+ const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][])
+ .filter(type => this.typesMap[type]);
+
+ this.$emit('done', { includingTypes });
+ this.$refs.dialog.close();
+ },
+
+ disableAll() {
+ for (const type in this.typesMap) {
+ this.typesMap[type as typeof notificationTypes[number]] = false;
+ }
+ },
+
+ enableAll() {
+ for (const type in this.typesMap) {
+ this.typesMap[type as typeof notificationTypes[number]] = true;
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue
new file mode 100644
index 0000000000..b629820043
--- /dev/null
+++ b/packages/client/src/components/notification.vue
@@ -0,0 +1,362 @@
+<template>
+<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }" ref="elRef">
+ <div class="head">
+ <MkAvatar v-if="notification.user" class="icon" :user="notification.user"/>
+ <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
+ <div class="sub-icon" :class="notification.type">
+ <i v-if="notification.type === 'follow'" class="fas fa-plus"></i>
+ <i v-else-if="notification.type === 'receiveFollowRequest'" class="fas fa-clock"></i>
+ <i v-else-if="notification.type === 'followRequestAccepted'" class="fas fa-check"></i>
+ <i v-else-if="notification.type === 'groupInvited'" class="fas fa-id-card-alt"></i>
+ <i v-else-if="notification.type === 'renote'" class="fas fa-retweet"></i>
+ <i v-else-if="notification.type === 'reply'" class="fas fa-reply"></i>
+ <i v-else-if="notification.type === 'mention'" class="fas fa-at"></i>
+ <i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i>
+ <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i>
+ <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
+ <XReactionIcon v-else-if="notification.type === 'reaction'"
+ :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
+ :custom-emojis="notification.note.emojis"
+ :no-style="true"
+ @touchstart.passive="onReactionMouseover"
+ @mouseover="onReactionMouseover"
+ @mouseleave="onReactionMouseleave"
+ @touchend="onReactionMouseleave"
+ ref="reactionRef"
+ />
+ </div>
+ </div>
+ <div class="tail">
+ <header>
+ <MkA v-if="notification.user" class="name" :to="userPage(notification.user)" v-user-preview="notification.user.id"><MkUserName :user="notification.user"/></MkA>
+ <span v-else>{{ notification.header }}</span>
+ <MkTime :time="notification.createdAt" v-if="withTime" class="time"/>
+ </header>
+ <MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <i class="fas fa-quote-left"></i>
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ <i class="fas fa-quote-right"></i>
+ </MkA>
+ <MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
+ <i class="fas fa-quote-left"></i>
+ <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/>
+ <i class="fas fa-quote-right"></i>
+ </MkA>
+ <MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ </MkA>
+ <MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ </MkA>
+ <MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ </MkA>
+ <MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <i class="fas fa-quote-left"></i>
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ <i class="fas fa-quote-right"></i>
+ </MkA>
+ <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
+ <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span>
+ <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span>
+ <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $ts.reject }}</button></div></span>
+ <span v-if="notification.type === 'app'" class="text">
+ <Mfm :text="notification.body" :nowrap="!full"/>
+ </span>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, onMounted, onUnmounted } from 'vue';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import XReactionIcon from './reaction-icon.vue';
+import MkFollowButton from './follow-button.vue';
+import XReactionTooltip from './reaction-tooltip.vue';
+import notePage from '@/filters/note';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XReactionIcon, MkFollowButton
+ },
+
+ props: {
+ notification: {
+ type: Object,
+ required: true,
+ },
+ withTime: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ setup(props) {
+ const elRef = ref<HTMLElement>(null);
+ const reactionRef = ref(null);
+
+ onMounted(() => {
+ let readObserver: IntersectionObserver = null;
+ let connection = null;
+
+ if (!props.notification.isRead) {
+ readObserver = new IntersectionObserver((entries, observer) => {
+ if (!entries.some(entry => entry.isIntersecting)) return;
+ os.stream.send('readNotification', {
+ id: props.notification.id
+ });
+ entries.map(({ target }) => observer.unobserve(target));
+ });
+
+ readObserver.observe(elRef.value);
+
+ connection = os.stream.useChannel('main');
+ connection.on('readAllNotifications', () => readObserver.unobserve(elRef.value));
+ }
+
+ onUnmounted(() => {
+ if (readObserver) readObserver.unobserve(elRef.value);
+ if (connection) connection.dispose();
+ });
+ });
+
+ const followRequestDone = ref(false);
+ const groupInviteDone = ref(false);
+
+ const acceptFollowRequest = () => {
+ followRequestDone.value = true;
+ os.api('following/requests/accept', { userId: props.notification.user.id });
+ };
+
+ const rejectFollowRequest = () => {
+ followRequestDone.value = true;
+ os.api('following/requests/reject', { userId: props.notification.user.id });
+ };
+
+ const acceptGroupInvitation = () => {
+ groupInviteDone.value = true;
+ os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id });
+ };
+
+ const rejectGroupInvitation = () => {
+ groupInviteDone.value = true;
+ os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id });
+ };
+
+ let isReactionHovering = false;
+ let reactionTooltipTimeoutId;
+
+ const onReactionMouseover = () => {
+ if (isReactionHovering) return;
+ isReactionHovering = true;
+ reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300);
+ };
+
+ const onReactionMouseleave = () => {
+ if (!isReactionHovering) return;
+ isReactionHovering = false;
+ clearTimeout(reactionTooltipTimeoutId);
+ closeReactionTooltip();
+ };
+
+ let changeReactionTooltipShowingState: () => void;
+
+ const openReactionTooltip = () => {
+ closeReactionTooltip();
+ if (!isReactionHovering) return;
+
+ const showing = ref(true);
+ os.popup(XReactionTooltip, {
+ showing,
+ reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction,
+ emojis: props.notification.note.emojis,
+ source: reactionRef.value.$el,
+ }, {}, 'closed');
+
+ changeReactionTooltipShowingState = () => {
+ showing.value = false;
+ };
+ };
+
+ const closeReactionTooltip = () => {
+ if (changeReactionTooltipShowingState != null) {
+ changeReactionTooltipShowingState();
+ changeReactionTooltipShowingState = null;
+ }
+ };
+
+ return {
+ getNoteSummary: (text: string) => getNoteSummary(text, i18n.locale),
+ followRequestDone,
+ groupInviteDone,
+ notePage,
+ userPage,
+ acceptFollowRequest,
+ rejectFollowRequest,
+ acceptGroupInvitation,
+ rejectGroupInvitation,
+ onReactionMouseover,
+ onReactionMouseleave,
+ elRef,
+ reactionRef,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.qglefbjs {
+ position: relative;
+ box-sizing: border-box;
+ padding: 24px 32px;
+ font-size: 0.9em;
+ overflow-wrap: break-word;
+ display: flex;
+ contain: content;
+
+ &.max-width_600px {
+ padding: 16px;
+ font-size: 0.9em;
+ }
+
+ &.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;
+
+ > .icon {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: 6px;
+ }
+
+ > .sub-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;
+ text-align: center;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ color: #fff;
+ width: 100%;
+ height: 100%;
+ }
+
+ &.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited {
+ padding: 3px;
+ background: #36aed2;
+ pointer-events: none;
+ }
+
+ &.renote {
+ padding: 3px;
+ background: #36d298;
+ pointer-events: none;
+ }
+
+ &.quote {
+ padding: 3px;
+ background: #36d298;
+ pointer-events: none;
+ }
+
+ &.reply {
+ padding: 3px;
+ background: #007aff;
+ pointer-events: none;
+ }
+
+ &.mention {
+ padding: 3px;
+ background: #88a6b7;
+ pointer-events: none;
+ }
+
+ &.pollVote {
+ padding: 3px;
+ background: #88a6b7;
+ pointer-events: none;
+ }
+ }
+ }
+
+ > .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;
+ }
+
+ > .time {
+ margin-left: auto;
+ font-size: 0.9em;
+ }
+ }
+
+ > .text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > i {
+ vertical-align: super;
+ font-size: 50%;
+ opacity: 0.5;
+ }
+
+ > i:first-child {
+ margin-right: 4px;
+ }
+
+ > i:last-child {
+ margin-left: 4px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue
new file mode 100644
index 0000000000..4ebb12c44b
--- /dev/null
+++ b/packages/client/src/components/notifications.vue
@@ -0,0 +1,159 @@
+<template>
+<transition name="fade" mode="out-in">
+ <MkLoading v-if="fetching"/>
+
+ <MkError v-else-if="error" @retry="init()"/>
+
+ <p class="mfcuwfyp" v-else-if="empty">{{ $ts.noNotifications }}</p>
+
+ <div v-else>
+ <XList class="elsfgstc" :items="items" v-slot="{ item: notification }" :no-gap="true">
+ <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/>
+ <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/>
+ </XList>
+
+ <MkButton primary style="margin: var(--margin) auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, markRaw } from 'vue';
+import paging from '@/scripts/paging';
+import XNotification from './notification.vue';
+import XList from './date-separated-list.vue';
+import XNote from './note.vue';
+import { notificationTypes } from 'misskey-js';
+import * as os from '@/os';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ XNotification,
+ XList,
+ XNote,
+ MkButton,
+ },
+
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ includeTypes: {
+ type: Array as PropType<typeof notificationTypes[number][]>,
+ required: false,
+ default: null,
+ },
+ unreadOnly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ connection: null,
+ pagination: {
+ endpoint: 'i/notifications',
+ limit: 10,
+ params: () => ({
+ includeTypes: this.allIncludeTypes || undefined,
+ unreadOnly: this.unreadOnly,
+ })
+ },
+ };
+ },
+
+ computed: {
+ allIncludeTypes() {
+ return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
+ }
+ },
+
+ watch: {
+ includeTypes: {
+ handler() {
+ this.reload();
+ },
+ deep: true
+ },
+ unreadOnly: {
+ handler() {
+ this.reload();
+ },
+ },
+ // TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、
+ // mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す
+ '$i.mutingNotificationTypes': {
+ handler() {
+ if (this.includeTypes === null) {
+ this.reload();
+ }
+ },
+ deep: true
+ }
+ },
+
+ mounted() {
+ this.connection = markRaw(os.stream.useChannel('main'));
+ this.connection.on('notification', this.onNotification);
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ onNotification(notification) {
+ const isMuted = !this.allIncludeTypes.includes(notification.type);
+ if (isMuted || document.visibilityState === 'visible') {
+ os.stream.send('readNotification', {
+ id: notification.id
+ });
+ }
+
+ if (!isMuted) {
+ this.prepend({
+ ...notification,
+ isRead: document.visibilityState === 'visible'
+ });
+ }
+ },
+
+ noteUpdated(oldValue, newValue) {
+ const i = this.items.findIndex(n => n.note === oldValue);
+ this.items[i] = {
+ ...this.items[i],
+ note: newValue
+ };
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.mfcuwfyp {
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+ color: var(--fg);
+}
+
+.elsfgstc {
+ background: var(--panel);
+}
+</style>
diff --git a/packages/client/src/components/number-diff.vue b/packages/client/src/components/number-diff.vue
new file mode 100644
index 0000000000..9889c97ec3
--- /dev/null
+++ b/packages/client/src/components/number-diff.vue
@@ -0,0 +1,47 @@
+<template>
+<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }">
+ <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot>
+</span>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import number from '@/filters/number';
+
+export default defineComponent({
+ props: {
+ value: {
+ type: Number,
+ required: true
+ },
+ },
+
+ setup(props) {
+ const isPlus = computed(() => props.value > 0);
+ const isMinus = computed(() => props.value < 0);
+ const isZero = computed(() => props.value === 0);
+ return {
+ isPlus,
+ isMinus,
+ isZero,
+ number,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ceaaebcd {
+ &.isPlus {
+ color: var(--success);
+ }
+
+ &.isMinus {
+ color: var(--error);
+ }
+
+ &.isZero {
+ opacity: 0.5;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page-preview.vue b/packages/client/src/components/page-preview.vue
new file mode 100644
index 0000000000..05df1dc16e
--- /dev/null
+++ b/packages/client/src/components/page-preview.vue
@@ -0,0 +1,162 @@
+<template>
+<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" 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>{{ userName(page.user) }}</p>
+ </footer>
+ </article>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { userName } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ page: {
+ type: Object,
+ required: true
+ },
+ },
+ methods: {
+ userName
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vhpxefrj {
+ display: block;
+
+ &:hover {
+ text-decoration: none;
+ color: var(--accent);
+ }
+
+ > .thumbnail {
+ width: 100%;
+ height: 200px;
+ 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/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue
new file mode 100644
index 0000000000..b6be114cd7
--- /dev/null
+++ b/packages/client/src/components/page-window.vue
@@ -0,0 +1,167 @@
+<template>
+<XWindow ref="window"
+ :initial-width="500"
+ :initial-height="500"
+ :can-resize="true"
+ :close-button="true"
+ :contextmenu="contextmenu"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ <template v-if="pageInfo">
+ <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i>
+ <span>{{ pageInfo.title }}</span>
+ </template>
+ </template>
+ <template #headerLeft>
+ <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button>
+ </template>
+ <div class="yrolvcoq">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
+ <component :is="component" v-bind="props" :ref="changePage"/>
+ </MkStickyContainer>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XWindow from '@/components/ui/window.vue';
+import { popout } from '@/scripts/popout';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { resolve } from '@/router';
+import { url } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ },
+
+ inject: {
+ sideViewHook: {
+ default: null
+ }
+ },
+
+ provide() {
+ return {
+ navHook: (path) => {
+ this.navigate(path);
+ },
+ shouldHeaderThin: true,
+ };
+ },
+
+ props: {
+ initialPath: {
+ type: String,
+ required: true,
+ },
+ initialComponent: {
+ type: Object,
+ required: true,
+ },
+ initialProps: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ pageInfo: null,
+ path: this.initialPath,
+ component: this.initialComponent,
+ props: this.initialProps,
+ history: [],
+ };
+ },
+
+ computed: {
+ url(): string {
+ return url + this.path;
+ },
+
+ contextmenu() {
+ return [{
+ type: 'label',
+ text: this.path,
+ }, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: this.expand
+ }, this.sideViewHook ? {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.sideViewHook(this.path);
+ this.$refs.window.close();
+ }
+ } : undefined, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.popout,
+ action: this.popout
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.url, '_blank');
+ this.$refs.window.close();
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(this.url);
+ }
+ }];
+ },
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ navigate(path, record = true) {
+ if (record) this.history.push(this.path);
+ this.path = path;
+ const { component, props } = resolve(path);
+ this.component = component;
+ this.props = props;
+ },
+
+ back() {
+ this.navigate(this.history.pop(), false);
+ },
+
+ close() {
+ this.$refs.window.close();
+ },
+
+ expand() {
+ this.$router.push(this.path);
+ this.$refs.window.close();
+ },
+
+ popout() {
+ popout(this.path, this.$el);
+ this.$refs.window.close();
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.yrolvcoq {
+ min-height: 100%;
+}
+</style>
diff --git a/packages/client/src/components/page/page.block.vue b/packages/client/src/components/page/page.block.vue
new file mode 100644
index 0000000000..54b8b30276
--- /dev/null
+++ b/packages/client/src/components/page/page.block.vue
@@ -0,0 +1,44 @@
+<template>
+<component :is="'x-' + block.type" :block="block" :hpml="hpml" :key="block.id" :h="h"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } 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';
+import XCanvas from './page.canvas.vue';
+import XNote from './page.note.vue';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { Block } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote
+ },
+ props: {
+ block: {
+ type: Object as PropType<Block>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ },
+ h: {
+ type: Number,
+ required: true
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/components/page/page.button.vue b/packages/client/src/components/page/page.button.vue
new file mode 100644
index 0000000000..51da84bd49
--- /dev/null
+++ b/packages/client/src/components/page/page.button.vue
@@ -0,0 +1,66 @@
+<template>
+<div>
+ <MkButton class="kudkigyw" @click="click()" :primary="block.primary">{{ hpml.interpolate(block.text) }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, unref } from 'vue';
+import MkButton from '../ui/button.vue';
+import * as os from '@/os';
+import { ButtonBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+ props: {
+ block: {
+ type: Object as PropType<ButtonBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ methods: {
+ click() {
+ if (this.block.action === 'dialog') {
+ this.hpml.eval();
+ os.dialog({
+ text: this.hpml.interpolate(this.block.content)
+ });
+ } else if (this.block.action === 'resetRandom') {
+ this.hpml.updateRandomSeed(Math.random());
+ this.hpml.eval();
+ } else if (this.block.action === 'pushEvent') {
+ os.api('page-push', {
+ pageId: this.hpml.page.id,
+ event: this.block.event,
+ ...(this.block.var ? {
+ var: unref(this.hpml.vars)[this.block.var]
+ } : {})
+ });
+
+ os.dialog({
+ type: 'success',
+ text: this.hpml.interpolate(this.block.message)
+ });
+ } else if (this.block.action === 'callAiScript') {
+ this.hpml.callAiScript(this.block.fn);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+ display: inline-block;
+ min-width: 200px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.canvas.vue b/packages/client/src/components/page/page.canvas.vue
new file mode 100644
index 0000000000..8f49b88e5e
--- /dev/null
+++ b/packages/client/src/components/page/page.canvas.vue
@@ -0,0 +1,49 @@
+<template>
+<div class="ysrxegms">
+ <canvas ref="canvas" :width="block.width" :height="block.height"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
+import * as os from '@/os';
+import { CanvasBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ props: {
+ block: {
+ type: Object as PropType<CanvasBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const canvas: Ref<any> = ref(null);
+
+ onMounted(() => {
+ props.hpml.registerCanvas(props.block.name, canvas.value);
+ });
+
+ return {
+ canvas
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ysrxegms {
+ display: inline-block;
+ vertical-align: bottom;
+ overflow: auto;
+ max-width: 100%;
+
+ > canvas {
+ display: block;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.counter.vue b/packages/client/src/components/page/page.counter.vue
new file mode 100644
index 0000000000..b1af8954b0
--- /dev/null
+++ b/packages/client/src/components/page/page.counter.vue
@@ -0,0 +1,52 @@
+<template>
+<div>
+ <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkButton from '../ui/button.vue';
+import * as os from '@/os';
+import { CounterVarBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+ props: {
+ block: {
+ type: Object as PropType<CounterVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function click() {
+ props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1));
+ props.hpml.eval();
+ }
+
+ return {
+ click
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.llumlmnx {
+ display: inline-block;
+ min-width: 300px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.if.vue b/packages/client/src/components/page/page.if.vue
new file mode 100644
index 0000000000..ec25332db0
--- /dev/null
+++ b/packages/client/src/components/page/page.if.vue
@@ -0,0 +1,31 @@
+<template>
+<div v-show="hpml.vars.value[block.var]">
+ <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h"/>
+</div>
+</template>
+
+<script lang="ts">
+import { IfBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineComponent, defineAsyncComponent, PropType } from 'vue';
+
+export default defineComponent({
+ components: {
+ XBlock: defineAsyncComponent(() => import('./page.block.vue'))
+ },
+ props: {
+ block: {
+ type: Object as PropType<IfBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ },
+ h: {
+ type: Number,
+ required: true
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/components/page/page.image.vue b/packages/client/src/components/page/page.image.vue
new file mode 100644
index 0000000000..04ce74bd7c
--- /dev/null
+++ b/packages/client/src/components/page/page.image.vue
@@ -0,0 +1,40 @@
+<template>
+<div class="lzyxtsnt">
+ <img v-if="image" :src="image.url"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import * as os from '@/os';
+import { ImageBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ props: {
+ block: {
+ type: Object as PropType<ImageBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId);
+
+ return {
+ image
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lzyxtsnt {
+ > img {
+ max-width: 100%;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.note.vue b/packages/client/src/components/page/page.note.vue
new file mode 100644
index 0000000000..925844c1bd
--- /dev/null
+++ b/packages/client/src/components/page/page.note.vue
@@ -0,0 +1,47 @@
+<template>
+<div class="voxdxuby">
+ <XNote v-if="note && !block.detailed" v-model:note="note" :key="note.id + ':normal'"/>
+ <XNoteDetailed v-if="note && block.detailed" v-model:note="note" :key="note.id + ':detail'"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
+import XNote from '@/components/note.vue';
+import XNoteDetailed from '@/components/note-detailed.vue';
+import * as os from '@/os';
+import { NoteBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ XNote,
+ XNoteDetailed,
+ },
+ props: {
+ block: {
+ type: Object as PropType<NoteBlock>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const note: Ref<Record<string, any> | null> = ref(null);
+
+ onMounted(() => {
+ os.api('notes/show', { noteId: props.block.note })
+ .then(result => {
+ note.value = result;
+ });
+ });
+
+ return {
+ note
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.voxdxuby {
+ margin: 1em 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.number-input.vue b/packages/client/src/components/page/page.number-input.vue
new file mode 100644
index 0000000000..b5120d0f85
--- /dev/null
+++ b/packages/client/src/components/page/page.number-input.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+ <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="number">
+ <template #label>{{ hpml.interpolate(block.text) }}</template>
+ </MkInput>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkInput from '../form/input.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { NumberInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkInput
+ },
+ props: {
+ block: {
+ type: Object as PropType<NumberInputVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+ display: inline-block;
+ min-width: 300px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.post.vue b/packages/client/src/components/page/page.post.vue
new file mode 100644
index 0000000000..1b86ea1ab9
--- /dev/null
+++ b/packages/client/src/components/page/page.post.vue
@@ -0,0 +1,109 @@
+<template>
+<div class="ngbfujlo">
+ <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea>
+ <MkButton class="button" primary @click="post()" :disabled="posting || posted">
+ <i v-if="posted" class="fas fa-check"></i>
+ <i v-else class="fas fa-paper-plane"></i>
+ </MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+import MkButton from '../ui/button.vue';
+import { apiUrl } from '@/config';
+import * as os from '@/os';
+import { PostBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ MkTextarea,
+ MkButton,
+ },
+ props: {
+ block: {
+ type: Object as PropType<PostBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ data() {
+ return {
+ text: this.hpml.interpolate(this.block.text),
+ posted: false,
+ posting: false,
+ };
+ },
+ watch: {
+ 'hpml.vars': {
+ handler() {
+ this.text = this.hpml.interpolate(this.block.text);
+ },
+ deep: true
+ }
+ },
+ methods: {
+ upload() {
+ const promise = new Promise((ok) => {
+ const canvas = this.hpml.canvases[this.block.canvasId];
+ canvas.toBlob(blob => {
+ const data = new FormData();
+ data.append('file', blob);
+ data.append('i', this.$i.token);
+ if (this.$store.state.uploadFolder) {
+ data.append('folderId', this.$store.state.uploadFolder);
+ }
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ ok(f);
+ })
+ });
+ });
+ os.promiseDialog(promise);
+ return promise;
+ },
+ async post() {
+ this.posting = true;
+ const file = this.block.attachCanvasImage ? await this.upload() : null;
+ os.apiWithDialog('notes/create', {
+ text: this.text === '' ? null : this.text,
+ fileIds: file ? [file.id] : undefined,
+ }).then(() => {
+ this.posted = true;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ngbfujlo {
+ position: relative;
+ padding: 32px;
+ border-radius: 6px;
+ box-shadow: 0 2px 8px var(--shadow);
+ z-index: 1;
+
+ > .button {
+ margin-top: 32px;
+ }
+
+ @media (max-width: 600px) {
+ padding: 16px;
+
+ > .button {
+ margin-top: 16px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.radio-button.vue b/packages/client/src/components/page/page.radio-button.vue
new file mode 100644
index 0000000000..4d3c03291e
--- /dev/null
+++ b/packages/client/src/components/page/page.radio-button.vue
@@ -0,0 +1,45 @@
+<template>
+<div>
+ <div>{{ hpml.interpolate(block.title) }}</div>
+ <MkRadio v-for="item in block.values" :modelValue="value" @update:modelValue="updateValue($event)" :value="item" :key="item">{{ item }}</MkRadio>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkRadio from '../form/radio.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { RadioButtonVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkRadio
+ },
+ props: {
+ block: {
+ type: Object as PropType<RadioButtonVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue: string) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
diff --git a/packages/client/src/components/page/page.section.vue b/packages/client/src/components/page/page.section.vue
new file mode 100644
index 0000000000..d32f5dc732
--- /dev/null
+++ b/packages/client/src/components/page/page.section.vue
@@ -0,0 +1,60 @@
+<template>
+<section class="sdgxphyu">
+ <component :is="'h' + h">{{ block.title }}</component>
+
+ <div class="children">
+ <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h + 1"/>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, PropType } from 'vue';
+import * as os from '@/os';
+import { SectionBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ XBlock: defineAsyncComponent(() => import('./page.block.vue'))
+ },
+ props: {
+ block: {
+ type: Object as PropType<SectionBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ },
+ h: {
+ required: true
+ }
+ },
+});
+</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/packages/client/src/components/page/page.switch.vue b/packages/client/src/components/page/page.switch.vue
new file mode 100644
index 0000000000..1ece88157f
--- /dev/null
+++ b/packages/client/src/components/page/page.switch.vue
@@ -0,0 +1,55 @@
+<template>
+<div class="hkcxmtwj">
+ <MkSwitch :model-value="value" @update:modelValue="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkSwitch from '../form/switch.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { SwitchVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkSwitch
+ },
+ props: {
+ block: {
+ type: Object as PropType<SwitchVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue: boolean) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hkcxmtwj {
+ display: inline-block;
+ margin: 16px auto;
+
+ & + .hkcxmtwj {
+ margin-left: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.text-input.vue b/packages/client/src/components/page/page.text-input.vue
new file mode 100644
index 0000000000..e4d3f6039a
--- /dev/null
+++ b/packages/client/src/components/page/page.text-input.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+ <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="text">
+ <template #label>{{ hpml.interpolate(block.text) }}</template>
+ </MkInput>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkInput from '../form/input.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { TextInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkInput
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextInputVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+ display: inline-block;
+ min-width: 300px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.text.vue b/packages/client/src/components/page/page.text.vue
new file mode 100644
index 0000000000..7dd41ed869
--- /dev/null
+++ b/packages/client/src/components/page/page.text.vue
@@ -0,0 +1,68 @@
+<template>
+<div class="mrdgzndn">
+ <Mfm :text="text" :is-note="false" :i="$i" :key="text"/>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" class="url"/>
+</div>
+</template>
+
+<script lang="ts">
+import { TextBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineAsyncComponent, defineComponent, PropType } from 'vue';
+import * as mfm from 'mfm-js';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+export default defineComponent({
+ components: {
+ MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ data() {
+ return {
+ text: this.hpml.interpolate(this.block.text),
+ };
+ },
+ computed: {
+ urls(): string[] {
+ if (this.text) {
+ return extractUrlFromMfm(mfm.parse(this.text));
+ } else {
+ return [];
+ }
+ }
+ },
+ watch: {
+ 'hpml.vars': {
+ handler() {
+ this.text = this.hpml.interpolate(this.block.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/packages/client/src/components/page/page.textarea-input.vue b/packages/client/src/components/page/page.textarea-input.vue
new file mode 100644
index 0000000000..6e082b2bef
--- /dev/null
+++ b/packages/client/src/components/page/page.textarea-input.vue
@@ -0,0 +1,47 @@
+<template>
+<div>
+ <MkTextarea :model-value="value" @update:modelValue="updateValue($event)">
+ <template #label>{{ hpml.interpolate(block.text) }}</template>
+ </MkTextarea>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { HpmlTextInput } from '@/scripts/hpml';
+import { TextInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkTextarea
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextInputVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
diff --git a/packages/client/src/components/page/page.textarea.vue b/packages/client/src/components/page/page.textarea.vue
new file mode 100644
index 0000000000..5b4ee2b452
--- /dev/null
+++ b/packages/client/src/components/page/page.textarea.vue
@@ -0,0 +1,39 @@
+<template>
+<MkTextarea :model-value="text" readonly></MkTextarea>
+</template>
+
+<script lang="ts">
+import { TextBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+
+export default defineComponent({
+ components: {
+ MkTextarea
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ data() {
+ return {
+ text: this.hpml.interpolate(this.block.text),
+ };
+ },
+ watch: {
+ 'hpml.vars': {
+ handler() {
+ this.text = this.hpml.interpolate(this.block.text);
+ },
+ deep: true
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue
new file mode 100644
index 0000000000..6d1c419a40
--- /dev/null
+++ b/packages/client/src/components/page/page.vue
@@ -0,0 +1,86 @@
+<template>
+<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }" v-if="hpml">
+ <XBlock v-for="child in page.content" :block="child" :hpml="hpml" :key="child.id" :h="2"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue';
+import { parse } from '@syuilo/aiscript';
+import XBlock from './page.block.vue';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { url } from '@/config';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ XBlock
+ },
+ props: {
+ page: {
+ type: Object as PropType<Record<string, any>>,
+ required: true
+ },
+ },
+ setup(props, ctx) {
+
+ const hpml = new Hpml(props.page, {
+ randomSeed: Math.random(),
+ visitor: $i,
+ url: url,
+ enableAiScript: !defaultStore.state.disablePagesScript
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (props.page.script && hpml.aiscript) {
+ let ast;
+ try {
+ ast = parse(props.page.script);
+ } catch (e) {
+ console.error(e);
+ /*os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });*/
+ return;
+ }
+ hpml.aiscript.exec(ast).then(() => {
+ hpml.eval();
+ }).catch(e => {
+ console.error(e);
+ /*os.dialog({
+ type: 'error',
+ text: e
+ });*/
+ });
+ } else {
+ hpml.eval();
+ }
+ });
+ onUnmounted(() => {
+ if (hpml.aiscript) hpml.aiscript.abort();
+ });
+ });
+
+ return {
+ hpml,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.iroscrza {
+ &.serif {
+ > div {
+ font-family: serif;
+ }
+ }
+
+ &.center {
+ text-align: center;
+ }
+}
+</style>
diff --git a/packages/client/src/components/particle.vue b/packages/client/src/components/particle.vue
new file mode 100644
index 0000000000..d82705c1e8
--- /dev/null
+++ b/packages/client/src/components/particle.vue
@@ -0,0 +1,114 @@
+<template>
+<div class="vswabwbm" :style="{ top: `${y - 64}px`, left: `${x - 64}px` }" :class="{ active }">
+ <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
+ <circle fill="none" cx="64" cy="64">
+ <animate attributeName="r"
+ begin="0s" dur="0.5s"
+ values="4; 32"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.165, 0.84, 0.44, 1"
+ repeatCount="1"
+ />
+ <animate attributeName="stroke-width"
+ begin="0s" dur="0.5s"
+ values="16; 0"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.3, 0.61, 0.355, 1"
+ repeatCount="1"
+ />
+ </circle>
+ <g fill="none" fill-rule="evenodd">
+ <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color">
+ <animate attributeName="r"
+ begin="0s" dur="0.8s"
+ :values="`${particle.size}; 0`"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.165, 0.84, 0.44, 1"
+ repeatCount="1"
+ />
+ <animate attributeName="cx"
+ begin="0s" dur="0.8s"
+ :values="`${particle.xA}; ${particle.xB}`"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.3, 0.61, 0.355, 1"
+ repeatCount="1"
+ />
+ <animate attributeName="cy"
+ begin="0s" dur="0.8s"
+ :values="`${particle.yA}; ${particle.yB}`"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.3, 0.61, 0.355, 1"
+ repeatCount="1"
+ />
+ </circle>
+ </g>
+ </svg>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ x: {
+ type: Number,
+ required: true
+ },
+ y: {
+ type: Number,
+ required: true
+ }
+ },
+ emits: ['end'],
+ data() {
+ const particles = [];
+ const origin = 64;
+ const colors = ['#FF1493', '#00FFFF', '#FFE202'];
+
+ for (let i = 0; i < 12; i++) {
+ const angle = Math.random() * (Math.PI * 2);
+ const pos = Math.random() * 16;
+ const velocity = 16 + (Math.random() * 48);
+ particles.push({
+ size: 4 + (Math.random() * 8),
+ xA: origin + (Math.sin(angle) * pos),
+ yA: origin + (Math.cos(angle) * pos),
+ xB: origin + (Math.sin(angle) * (pos + velocity)),
+ yB: origin + (Math.cos(angle) * (pos + velocity)),
+ color: colors[Math.floor(Math.random() * colors.length)]
+ });
+ }
+
+ return {
+ particles
+ };
+ },
+ mounted() {
+ setTimeout(() => {
+ this.$emit('end');
+ }, 1100);
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vswabwbm {
+ pointer-events: none;
+ position: fixed;
+ z-index: 1000000;
+ width: 128px;
+ height: 128px;
+
+ > svg {
+ > circle {
+ stroke: var(--accent);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/poll-editor.vue
new file mode 100644
index 0000000000..aa213cfe49
--- /dev/null
+++ b/packages/client/src/components/poll-editor.vue
@@ -0,0 +1,251 @@
+<template>
+<div class="zmdxowus">
+ <p class="caution" v-if="choices.length < 2">
+ <i class="fas fa-exclamation-triangle"></i>{{ $ts._poll.noOnlyOneChoice }}
+ </p>
+ <ul ref="choices">
+ <li v-for="(choice, i) in choices" :key="i">
+ <MkInput class="input" :model-value="choice" @update:modelValue="onInput(i, $event)" :placeholder="$t('_poll.choiceN', { n: i + 1 })">
+ </MkInput>
+ <button @click="remove(i)" class="_button">
+ <i class="fas fa-times"></i>
+ </button>
+ </li>
+ </ul>
+ <MkButton class="add" v-if="choices.length < 10" @click="add">{{ $ts.add }}</MkButton>
+ <MkButton class="add" v-else disabled>{{ $ts._poll.noMore }}</MkButton>
+ <section>
+ <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch>
+ <div>
+ <MkSelect v-model="expiration">
+ <template #label>{{ $ts._poll.expiration }}</template>
+ <option value="infinite">{{ $ts._poll.infinite }}</option>
+ <option value="at">{{ $ts._poll.at }}</option>
+ <option value="after">{{ $ts._poll.after }}</option>
+ </MkSelect>
+ <section v-if="expiration === 'at'">
+ <MkInput v-model="atDate" type="date" class="input">
+ <template #label>{{ $ts._poll.deadlineDate }}</template>
+ </MkInput>
+ <MkInput v-model="atTime" type="time" class="input">
+ <template #label>{{ $ts._poll.deadlineTime }}</template>
+ </MkInput>
+ </section>
+ <section v-if="expiration === 'after'">
+ <MkInput v-model="after" type="number" class="input">
+ <template #label>{{ $ts._poll.duration }}</template>
+ </MkInput>
+ <MkSelect v-model="unit">
+ <option value="second">{{ $ts._time.second }}</option>
+ <option value="minute">{{ $ts._time.minute }}</option>
+ <option value="hour">{{ $ts._time.hour }}</option>
+ <option value="day">{{ $ts._time.day }}</option>
+ </MkSelect>
+ </section>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { addTime } from '@/scripts/time';
+import { formatDateTimeString } from '@/scripts/format-time-string';
+import MkInput from './form/input.vue';
+import MkSelect from './form/select.vue';
+import MkSwitch from './form/switch.vue';
+import MkButton from './ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkInput,
+ MkSelect,
+ MkSwitch,
+ MkButton,
+ },
+
+ props: {
+ poll: {
+ type: Object,
+ required: true
+ }
+ },
+
+ emits: ['updated'],
+
+ data() {
+ return {
+ choices: this.poll.choices,
+ multiple: this.poll.multiple,
+ expiration: 'infinite',
+ atDate: formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'),
+ atTime: '00:00',
+ after: 0,
+ unit: 'second',
+ };
+ },
+
+ watch: {
+ choices: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ deep: true
+ },
+ multiple: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ expiration: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ atDate: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ after: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ unit: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ },
+
+ created() {
+ const poll = this.poll;
+ if (poll.expiresAt) {
+ this.expiration = 'at';
+ this.atDate = this.atTime = poll.expiresAt;
+ } else if (typeof poll.expiredAfter === 'number') {
+ this.expiration = 'after';
+ this.after = poll.expiredAfter / 1000;
+ } else {
+ this.expiration = 'infinite';
+ }
+ },
+
+ methods: {
+ onInput(i, e) {
+ this.choices[i] = e;
+ },
+
+ add() {
+ this.choices.push('');
+ this.$nextTick(() => {
+ // TODO
+ //(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: this.choices,
+ multiple: this.multiple,
+ ...(
+ this.expiration === 'at' ? { expiresAt: at() } :
+ this.expiration === 'after' ? { expiredAfter: after() } : {}
+ )
+ };
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zmdxowus {
+ padding: 8px;
+
+ > .caution {
+ margin: 0 0 8px 0;
+ font-size: 0.8em;
+ color: #f00;
+
+ > i {
+ 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/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue
new file mode 100644
index 0000000000..049fe3a435
--- /dev/null
+++ b/packages/client/src/components/poll.vue
@@ -0,0 +1,174 @@
+<template>
+<div class="tivcixzd" :class="{ 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"><i class="fas fa-check"></i></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 v-if="!readOnly">
+ <span>{{ $t('_poll.totalVotes', { n: total }) }}</span>
+ <span> · </span>
+ <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $ts._poll.vote : $ts._poll.showResult }}</a>
+ <span v-if="isVoted">{{ $ts._poll.voted }}</span>
+ <span v-else-if="closed">{{ $ts._poll.closed }}</span>
+ <span v-if="remaining > 0"> · {{ timer }}</span>
+ </p>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { sum } from '@/scripts/array';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ readOnly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+ data() {
+ return {
+ remaining: -1,
+ showResult: false,
+ };
+ },
+ 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.readOnly || 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.readOnly || this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return;
+ os.api('notes/polls/vote', {
+ noteId: this.note.id,
+ choice: id
+ }).then(() => {
+ if (!this.showResult) this.showResult = !this.poll.multiple;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tivcixzd {
+ > ul {
+ display: block;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ > li {
+ display: block;
+ position: relative;
+ margin: 4px 0;
+ padding: 4px 8px;
+ border: solid 0.5px var(--divider);
+ 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;
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > .votes {
+ margin-left: 4px;
+ }
+ }
+ }
+ }
+
+ > p {
+ color: var(--fg);
+
+ a {
+ color: inherit;
+ }
+ }
+
+ &.done {
+ > ul > li {
+ cursor: default;
+
+ &:hover {
+ background: transparent;
+ }
+
+ &:active {
+ background: transparent;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue
new file mode 100644
index 0000000000..dff0dec21e
--- /dev/null
+++ b/packages/client/src/components/post-form-attaches.vue
@@ -0,0 +1,193 @@
+<template>
+<div class="skeikyzd" v-show="files.length != 0">
+ <XDraggable class="files" v-model="_files" item-key="id" animation="150" delay="100" delay-on-touch-only="true">
+ <template #item="{element}">
+ <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
+ <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/>
+ <div class="sensitive" v-if="element.isSensitive">
+ <i class="fas fa-exclamation-triangle icon"></i>
+ </div>
+ </div>
+ </template>
+ </XDraggable>
+ <p class="remain">{{ 4 - files.length }}/4</p>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import MkDriveFileThumbnail from './drive-file-thumbnail.vue'
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ MkDriveFileThumbnail
+ },
+
+ props: {
+ files: {
+ type: Array,
+ required: true
+ },
+ detachMediaFn: {
+ type: Function,
+ required: false
+ }
+ },
+
+ emits: ['updated', 'detach', 'changeSensitive', 'changeName'],
+
+ data() {
+ return {
+ menu: null as Promise<null> | null,
+
+ };
+ },
+
+ computed: {
+ _files: {
+ get() {
+ return this.files;
+ },
+ set(value) {
+ this.$emit('updated', value);
+ }
+ }
+ },
+
+ methods: {
+ detachMedia(id) {
+ if (this.detachMediaFn) {
+ this.detachMediaFn(id);
+ } else {
+ this.$emit('detach', id);
+ }
+ },
+ toggleSensitive(file) {
+ os.api('drive/files/update', {
+ fileId: file.id,
+ isSensitive: !file.isSensitive
+ }).then(() => {
+ this.$emit('changeSensitive', file, !file.isSensitive);
+ });
+ },
+ async rename(file) {
+ const { canceled, result } = await os.dialog({
+ title: this.$ts.enterFileName,
+ input: {
+ default: file.name
+ },
+ allowEmpty: false
+ });
+ if (canceled) return;
+ os.api('drive/files/update', {
+ fileId: file.id,
+ name: result
+ }).then(() => {
+ this.$emit('changeName', file, result);
+ file.name = result;
+ });
+ },
+
+ async describe(file) {
+ os.popup(import("@/components/media-caption.vue"), {
+ title: this.$ts.describeFile,
+ input: {
+ placeholder: this.$ts.inputNewDescription,
+ default: file.comment !== null ? file.comment : "",
+ },
+ image: file
+ }, {
+ done: result => {
+ if (!result || result.canceled) return;
+ let comment = result.result;
+ os.api('drive/files/update', {
+ fileId: file.id,
+ comment: comment.length == 0 ? null : comment
+ });
+ }
+ }, 'closed');
+ },
+
+ showFileMenu(file, ev: MouseEvent) {
+ if (this.menu) return;
+ this.menu = os.popupMenu([{
+ text: this.$ts.renameFile,
+ icon: 'fas fa-i-cursor',
+ action: () => { this.rename(file) }
+ }, {
+ text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
+ icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye',
+ action: () => { this.toggleSensitive(file) }
+ }, {
+ text: this.$ts.describeFile,
+ icon: 'fas fa-i-cursor',
+ action: () => { this.describe(file) }
+ }, {
+ text: this.$ts.attachCancel,
+ icon: 'fas fa-times-circle',
+ action: () => { this.detachMedia(file.id) }
+ }], ev.currentTarget || ev.target).then(() => this.menu = null);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.skeikyzd {
+ padding: 8px 16px;
+ position: relative;
+
+ > .files {
+ display: flex;
+ flex-wrap: wrap;
+
+ > div {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ margin-right: 4px;
+ border-radius: 4px;
+ overflow: hidden;
+ 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/packages/client/src/components/post-form-dialog.vue b/packages/client/src/components/post-form-dialog.vue
new file mode 100644
index 0000000000..ae1cd7f01e
--- /dev/null
+++ b/packages/client/src/components/post-form-dialog.vue
@@ -0,0 +1,19 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')" :position="'top'">
+ <MkPostForm @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()" v-bind="$attrs"/>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import MkPostForm from '@/components/post-form.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkPostForm,
+ },
+ emits: ['closed'],
+});
+</script>
diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue
new file mode 100644
index 0000000000..ce6b7db3ee
--- /dev/null
+++ b/packages/client/src/components/post-form.vue
@@ -0,0 +1,980 @@
+<template>
+<div class="gafaadew" :class="{ modal, _popup: modal }"
+ v-size="{ max: [310, 500] }"
+ @dragover.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.stop="onDrop"
+>
+ <header>
+ <button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button>
+ <div>
+ <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
+ <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span>
+ <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null">
+ <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
+ <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span>
+ <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span>
+ <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span>
+ </button>
+ <button class="_button preview" @click="showPreview = !showPreview" :class="{ active: showPreview }" v-tooltip="$ts.previewNoteText"><i class="fas fa-file-code"></i></button>
+ <button class="submit _buttonGradate" :disabled="!canPost" @click="post" data-cy-open-post-form-submit>{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button>
+ </div>
+ </header>
+ <div class="form" :class="{ fixed }">
+ <XNoteSimple class="preview" v-if="reply" :note="reply"/>
+ <XNoteSimple class="preview" v-if="renote" :note="renote"/>
+ <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div>
+ <div v-if="visibility === 'specified'" class="to-specified">
+ <span style="margin-right: 8px;">{{ $ts.recipient }}</span>
+ <div class="visibleUsers">
+ <span v-for="u in visibleUsers" :key="u.id">
+ <MkAcct :user="u"/>
+ <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button>
+ </span>
+ <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button>
+ </div>
+ </div>
+ <MkInfo warn v-if="hasNotSpecifiedMentions" class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo>
+ <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
+ <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" data-cy-post-form-text/>
+ <input v-show="withHashtags" ref="hashtags" class="hashtags" v-model="hashtags" :placeholder="$ts.hashtags" list="hashtags">
+ <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
+ <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
+ <XNotePreview class="preview" v-if="showPreview" :text="text"/>
+ <footer>
+ <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button>
+ <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button>
+ <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button>
+ <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button>
+ <button class="_button" @click="withHashtags = !withHashtags" :class="{ active: withHashtags }" v-tooltip="$ts.hashtags"><i class="fas fa-hashtag"></i></button>
+ <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button>
+ <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button>
+ </footer>
+ <datalist id="hashtags">
+ <option v-for="hashtag in recentHashtags" :value="hashtag" :key="hashtag"/>
+ </datalist>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import { length } from 'stringz';
+import { toASCII } from 'punycode/';
+import XNoteSimple from './note-simple.vue';
+import XNotePreview from './note-preview.vue';
+import * as mfm from 'mfm-js';
+import { host, url } from '@/config';
+import { erase, unique } from '@/scripts/array';
+import { extractMentions } from '@/scripts/extract-mentions';
+import * as Acct from 'misskey-js/built/acct';
+import { formatTimeString } from '@/scripts/format-time-string';
+import { Autocomplete } from '@/scripts/autocomplete';
+import { noteVisibilities } from 'misskey-js';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
+import { isMobile } from '@/scripts/is-mobile';
+import { throttle } from 'throttle-debounce';
+import MkInfo from '@/components/ui/info.vue';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ XNoteSimple,
+ XNotePreview,
+ XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')),
+ XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')),
+ MkInfo,
+ },
+
+ inject: ['modal'],
+
+ props: {
+ reply: {
+ type: Object,
+ required: false
+ },
+ renote: {
+ type: Object,
+ required: false
+ },
+ channel: {
+ type: Object,
+ required: false
+ },
+ mention: {
+ type: Object,
+ required: false
+ },
+ specified: {
+ type: Object,
+ required: false
+ },
+ initialText: {
+ type: String,
+ required: false
+ },
+ initialVisibility: {
+ type: String,
+ required: false
+ },
+ initialFiles: {
+ type: Array,
+ required: false
+ },
+ initialLocalOnly: {
+ type: Boolean,
+ required: false
+ },
+ visibleUsers: {
+ type: Array,
+ required: false,
+ default: () => []
+ },
+ initialNote: {
+ type: Object,
+ required: false
+ },
+ share: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ fixed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ },
+
+ emits: ['posted', 'cancel', 'esc'],
+
+ data() {
+ return {
+ posting: false,
+ text: '',
+ files: [],
+ poll: null,
+ useCw: false,
+ showPreview: false,
+ cw: null,
+ localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
+ visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number],
+ autocomplete: null,
+ draghover: false,
+ quoteId: null,
+ hasNotSpecifiedMentions: false,
+ recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
+ imeText: '',
+ typing: throttle(3000, () => {
+ if (this.channel) {
+ os.stream.send('typingOnChannel', { channel: this.channel.id });
+ }
+ }),
+ postFormActions,
+ };
+ },
+
+ computed: {
+ draftKey(): string {
+ let key = this.channel ? `channel:${this.channel.id}` : '';
+
+ if (this.renote) {
+ key += `renote:${this.renote.id}`;
+ } else if (this.reply) {
+ key += `reply:${this.reply.id}`;
+ } else {
+ key += 'note';
+ }
+
+ return key;
+ },
+
+ placeholder(): string {
+ if (this.renote) {
+ return this.$ts._postForm.quotePlaceholder;
+ } else if (this.reply) {
+ return this.$ts._postForm.replyPlaceholder;
+ } else if (this.channel) {
+ return this.$ts._postForm.channelPlaceholder;
+ } else {
+ const xs = [
+ this.$ts._postForm._placeholders.a,
+ this.$ts._postForm._placeholders.b,
+ this.$ts._postForm._placeholders.c,
+ this.$ts._postForm._placeholders.d,
+ this.$ts._postForm._placeholders.e,
+ this.$ts._postForm._placeholders.f
+ ];
+ return xs[Math.floor(Math.random() * xs.length)];
+ }
+ },
+
+ submitText(): string {
+ return this.renote
+ ? this.$ts.quote
+ : this.reply
+ ? this.$ts.reply
+ : this.$ts.note;
+ },
+
+ textLength(): number {
+ return length((this.text + this.imeText).trim());
+ },
+
+ canPost(): boolean {
+ return !this.posting &&
+ (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
+ (this.textLength <= this.max) &&
+ (!this.poll || this.poll.choices.length >= 2);
+ },
+
+ max(): number {
+ return this.$instance ? this.$instance.maxNoteTextLength : 1000;
+ },
+
+ withHashtags: defaultStore.makeGetterSetter('postFormWithHashtags'),
+ hashtags: defaultStore.makeGetterSetter('postFormHashtags'),
+ },
+
+ watch: {
+ text() {
+ this.checkMissingMention();
+ },
+ visibleUsers: {
+ handler() {
+ this.checkMissingMention();
+ },
+ deep: true
+ }
+ },
+
+ mounted() {
+ if (this.initialText) {
+ this.text = this.initialText;
+ }
+
+ if (this.initialVisibility) {
+ this.visibility = this.initialVisibility;
+ }
+
+ if (this.initialFiles) {
+ this.files = this.initialFiles;
+ }
+
+ if (typeof this.initialLocalOnly === 'boolean') {
+ this.localOnly = this.initialLocalOnly;
+ }
+
+ 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 = mfm.parse(this.reply.text);
+
+ for (const x of extractMentions(ast)) {
+ const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
+
+ // 自分は除外
+ if (this.$i.username == x.username && x.host == null) continue;
+ if (this.$i.username == x.username && x.host == host) continue;
+
+ // 重複は除外
+ if (this.text.indexOf(`${mention} `) != -1) continue;
+
+ this.text += `${mention} `;
+ }
+ }
+
+ if (this.channel) {
+ this.visibility = 'public';
+ this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
+ }
+
+ // 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+ if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
+ this.visibility = this.reply.visibility;
+ if (this.reply.visibility === 'specified') {
+ os.api('users/show', {
+ userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
+ }).then(users => {
+ this.visibleUsers.push(...users);
+ });
+
+ if (this.reply.userId !== this.$i.id) {
+ os.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.keepCw && this.reply && this.reply.cw) {
+ this.useCw = true;
+ this.cw = this.reply.cw;
+ }
+
+ if (this.autofocus) {
+ this.focus();
+
+ this.$nextTick(() => {
+ this.focus();
+ });
+ }
+
+ // TODO: detach when unmount
+ new Autocomplete(this.$refs.text, this, { model: 'text' });
+ new Autocomplete(this.$refs.cw, this, { model: 'cw' });
+ new Autocomplete(this.$refs.hashtags, this, { model: 'hashtags' });
+
+ this.$nextTick(() => {
+ // 書きかけの投稿を復元
+ if (!this.share && !this.mention && !this.specified) {
+ const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
+ if (draft) {
+ this.text = draft.data.text;
+ this.useCw = draft.data.useCw;
+ this.cw = draft.data.cw;
+ this.visibility = draft.data.visibility;
+ this.localOnly = draft.data.localOnly;
+ this.files = (draft.data.files || []).filter(e => e);
+ if (draft.data.poll) {
+ this.poll = draft.data.poll;
+ }
+ }
+ }
+
+ // 削除して編集
+ 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 = {
+ choices: init.poll.choices.map(x => x.text),
+ multiple: init.poll.multiple,
+ expiresAt: init.poll.expiresAt,
+ expiredAfter: init.poll.expiredAfter,
+ };
+ }
+ this.visibility = init.visibility;
+ this.localOnly = init.localOnly;
+ this.quoteId = init.renote ? init.renote.id : null;
+ }
+
+ this.$nextTick(() => this.watch());
+ });
+ },
+
+ methods: {
+ watch() {
+ this.$watch('text', () => this.saveDraft());
+ this.$watch('useCw', () => this.saveDraft());
+ this.$watch('cw', () => this.saveDraft());
+ this.$watch('poll', () => this.saveDraft());
+ this.$watch('files', () => this.saveDraft(), { deep: true });
+ this.$watch('visibility', () => this.saveDraft());
+ this.$watch('localOnly', () => this.saveDraft());
+ },
+
+ checkMissingMention() {
+ if (this.visibility === 'specified') {
+ const ast = mfm.parse(this.text);
+
+ for (const x of extractMentions(ast)) {
+ if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+ this.hasNotSpecifiedMentions = true;
+ return;
+ }
+ }
+ this.hasNotSpecifiedMentions = false;
+ }
+ },
+
+ addMissingMention() {
+ const ast = mfm.parse(this.text);
+
+ for (const x of extractMentions(ast)) {
+ if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+ os.api('users/show', { username: x.username, host: x.host }).then(user => {
+ this.visibleUsers.push(user);
+ });
+ }
+ }
+ },
+
+ togglePoll() {
+ if (this.poll) {
+ this.poll = null;
+ } else {
+ this.poll = {
+ choices: ['', ''],
+ multiple: false,
+ expiresAt: null,
+ expiredAfter: null,
+ };
+ }
+ },
+
+ addTag(tag: string) {
+ insertTextAtCursor(this.$refs.text, ` #${tag} `);
+ },
+
+ focus() {
+ (this.$refs.text as any).focus();
+ },
+
+ chooseFileFrom(ev) {
+ selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => {
+ for (const file of files) {
+ this.files.push(file);
+ }
+ });
+ },
+
+ detachFile(id) {
+ this.files = this.files.filter(x => x.id != id);
+ },
+
+ updateFiles(files) {
+ this.files = files;
+ },
+
+ updateFileSensitive(file, sensitive) {
+ this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+ },
+
+ updateFileName(file, name) {
+ this.files[this.files.findIndex(x => x.id === file.id)].name = name;
+ },
+
+ upload(file: File, name?: string) {
+ os.upload(file, this.$store.state.uploadFolder, name).then(res => {
+ this.files.push(res);
+ });
+ },
+
+ onPollUpdate(poll) {
+ this.poll = poll;
+ this.saveDraft();
+ },
+
+ setVisibility() {
+ if (this.channel) {
+ // TODO: information dialog
+ return;
+ }
+
+ os.popup(import('./visibility-picker.vue'), {
+ currentVisibility: this.visibility,
+ currentLocalOnly: this.localOnly,
+ src: this.$refs.visibilityButton
+ }, {
+ changeVisibility: visibility => {
+ this.visibility = visibility;
+ if (this.$store.state.rememberNoteVisibility) {
+ this.$store.set('visibility', visibility);
+ }
+ },
+ changeLocalOnly: localOnly => {
+ this.localOnly = localOnly;
+ if (this.$store.state.rememberNoteVisibility) {
+ this.$store.set('localOnly', localOnly);
+ }
+ }
+ }, 'closed');
+ },
+
+ addVisibleUser() {
+ os.selectUser().then(user => {
+ this.visibleUsers.push(user);
+ });
+ },
+
+ removeVisibleUser(user) {
+ this.visibleUsers = erase(user, this.visibleUsers);
+ },
+
+ clear() {
+ this.text = '';
+ this.files = [];
+ this.poll = null;
+ this.quoteId = null;
+ },
+
+ onKeydown(e: KeyboardEvent) {
+ if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
+ if (e.which === 27) this.$emit('esc');
+ this.typing();
+ },
+
+ onCompositionUpdate(e: CompositionEvent) {
+ this.imeText = e.data;
+ this.typing();
+ },
+
+ onCompositionEnd(e: CompositionEvent) {
+ this.imeText = '';
+ },
+
+ 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.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();
+
+ os.dialog({
+ type: 'info',
+ text: this.$ts.quoteQuestion,
+ 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] == _DATA_TRANSFER_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(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.files.push(file);
+ e.preventDefault();
+ }
+ //#endregion
+ },
+
+ saveDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+ data[this.draftKey] = {
+ updatedAt: new Date(),
+ data: {
+ text: this.text,
+ useCw: this.useCw,
+ cw: this.cw,
+ visibility: this.visibility,
+ localOnly: this.localOnly,
+ files: this.files,
+ poll: this.poll
+ }
+ };
+
+ localStorage.setItem('drafts', JSON.stringify(data));
+ },
+
+ deleteDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+ delete data[this.draftKey];
+
+ localStorage.setItem('drafts', JSON.stringify(data));
+ },
+
+ async post() {
+ let data = {
+ 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,
+ channelId: this.channel ? this.channel.id : undefined,
+ poll: this.poll,
+ cw: this.useCw ? this.cw || '' : undefined,
+ localOnly: this.localOnly,
+ visibility: this.visibility,
+ visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
+ viaMobile: isMobile
+ };
+
+ if (this.withHashtags && this.hashtags && this.hashtags.trim() !== '') {
+ const hashtags = this.hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
+ data.text = data.text ? `${data.text} ${hashtags}` : hashtags;
+ }
+
+ // plugin
+ if (notePostInterruptors.length > 0) {
+ for (const interruptor of notePostInterruptors) {
+ data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
+ }
+ }
+
+ this.posting = true;
+ os.api('notes/create', data).then(() => {
+ this.clear();
+ this.$nextTick(() => {
+ this.deleteDraft();
+ this.$emit('posted');
+ if (data.text && data.text != '') {
+ const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
+ const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
+ localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
+ }
+ this.posting = false;
+ });
+ }).catch(err => {
+ this.posting = false;
+ os.dialog({
+ type: 'error',
+ text: err.message + '\n' + (err as any).id,
+ });
+ });
+ },
+
+ cancel() {
+ this.$emit('cancel');
+ },
+
+ insertMention() {
+ os.selectUser().then(user => {
+ insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' ');
+ });
+ },
+
+ async insertEmoji(ev) {
+ os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
+ },
+
+ showActions(ev) {
+ os.popupMenu(postFormActions.map(action => ({
+ text: action.title,
+ action: () => {
+ action.handler({
+ text: this.text
+ }, (key, value) => {
+ if (key === 'text') { this.text = value; }
+ });
+ }
+ })), ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.gafaadew {
+ position: relative;
+
+ &.modal {
+ width: 100%;
+ max-width: 520px;
+ }
+
+ > header {
+ z-index: 1000;
+ height: 66px;
+
+ > .cancel {
+ padding: 0;
+ font-size: 20px;
+ width: 64px;
+ line-height: 66px;
+ }
+
+ > div {
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ > .text-count {
+ opacity: 0.7;
+ line-height: 66px;
+ }
+
+ > .visibility {
+ height: 34px;
+ width: 34px;
+ margin: 0 0 0 8px;
+
+ & + .localOnly {
+ margin-left: 0 !important;
+ }
+ }
+
+ > .local-only {
+ margin: 0 0 0 12px;
+ opacity: 0.7;
+ }
+
+ > .preview {
+ display: inline-block;
+ padding: 0;
+ margin: 0 8px 0 0;
+ font-size: 16px;
+ width: 34px;
+ height: 34px;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--X5);
+ }
+
+ &.active {
+ color: var(--accent);
+ }
+ }
+
+ > .submit {
+ margin: 16px 16px 16px 0;
+ padding: 0 12px;
+ line-height: 34px;
+ font-weight: bold;
+ vertical-align: bottom;
+ border-radius: 4px;
+ font-size: 0.9em;
+
+ &:disabled {
+ opacity: 0.7;
+ }
+
+ > i {
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+
+ > .form {
+ > .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;
+
+ > .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(--X4);
+
+ > button {
+ padding: 4px 8px;
+ }
+ }
+ }
+ }
+
+ > .hasNotSpecifiedMentions {
+ margin: 0 20px 16px 20px;
+ }
+
+ > .cw,
+ > .hashtags,
+ > .text {
+ 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: inherit;
+
+ &:focus {
+ outline: none;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ }
+ }
+
+ > .cw {
+ z-index: 1;
+ padding-bottom: 8px;
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .hashtags {
+ z-index: 1;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .text {
+ max-width: 100%;
+ min-width: 100%;
+ min-height: 90px;
+
+ &.withCw {
+ padding-top: 8px;
+ }
+ }
+
+ > footer {
+ padding: 0 16px 16px 16px;
+
+ > button {
+ display: inline-block;
+ padding: 0;
+ margin: 0;
+ font-size: 16px;
+ width: 48px;
+ height: 48px;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--X5);
+ }
+
+ &.active {
+ color: var(--accent);
+ }
+ }
+ }
+ }
+
+ &.max-width_500px {
+ > header {
+ height: 50px;
+
+ > .cancel {
+ width: 50px;
+ line-height: 50px;
+ }
+
+ > div {
+ > .text-count {
+ line-height: 50px;
+ }
+
+ > .submit {
+ margin: 8px;
+ }
+ }
+ }
+
+ > .form {
+ > .to-specified {
+ padding: 6px 16px;
+ }
+
+ > .cw,
+ > .hashtags,
+ > .text {
+ padding: 0 16px;
+ }
+
+ > .text {
+ min-height: 80px;
+ }
+
+ > footer {
+ padding: 0 8px 8px 8px;
+ }
+ }
+ }
+
+ &.max-width_310px {
+ > .form {
+ > footer {
+ > button {
+ font-size: 14px;
+ width: 44px;
+ height: 44px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue
new file mode 100644
index 0000000000..7e0ed58cbd
--- /dev/null
+++ b/packages/client/src/components/queue-chart.vue
@@ -0,0 +1,232 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+export default defineComponent({
+ props: {
+ domain: {
+ type: String,
+ required: true,
+ },
+ connection: {
+ required: true,
+ },
+ },
+
+ setup(props) {
+ const chartEl = ref<HTMLCanvasElement>(null);
+
+ const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ onMounted(() => {
+ const chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Process',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00E396',
+ backgroundColor: alpha('#00E396', 0.1),
+ data: []
+ }, {
+ label: 'Active',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00BCD4',
+ backgroundColor: alpha('#00BCD4', 0.1),
+ data: []
+ }, {
+ label: 'Waiting',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#FFB300',
+ backgroundColor: alpha('#FFB300', 0.1),
+ yAxisID: 'y2',
+ data: []
+ }, {
+ label: 'Delayed',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#E53935',
+ borderDash: [5, 5],
+ fill: false,
+ yAxisID: 'y2',
+ data: []
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 8,
+ },
+ },
+ scales: {
+ x: {
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: false,
+ maxTicksLimit: 10
+ },
+ },
+ y: {
+ min: 0,
+ stack: 'queue',
+ stackWeight: 2,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ y2: {
+ min: 0,
+ offset: true,
+ stack: 'queue',
+ stackWeight: 1,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ },
+ },
+ tooltip: {
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ },
+ },
+ },
+ });
+
+ const onStats = (stats) => {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ chartInstance.update();
+ };
+
+ const onStatsLog = (statsLog) => {
+ for (const stats of [...statsLog].reverse()) {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ }
+ chartInstance.update();
+ };
+
+ props.connection.on('stats', onStats);
+ props.connection.on('statsLog', onStatsLog);
+
+ onUnmounted(() => {
+ props.connection.off('stats', onStats);
+ props.connection.off('statsLog', onStatsLog);
+ });
+ });
+
+ return {
+ chartEl,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/components/reaction-icon.vue b/packages/client/src/components/reaction-icon.vue
new file mode 100644
index 0000000000..c0ec955e32
--- /dev/null
+++ b/packages/client/src/components/reaction-icon.vue
@@ -0,0 +1,25 @@
+<template>
+<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ reaction: {
+ type: String,
+ required: true
+ },
+ customEmojis: {
+ required: false,
+ default: () => []
+ },
+ noStyle: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+});
+</script>
diff --git a/packages/client/src/components/reaction-tooltip.vue b/packages/client/src/components/reaction-tooltip.vue
new file mode 100644
index 0000000000..93143cbe81
--- /dev/null
+++ b/packages/client/src/components/reaction-tooltip.vue
@@ -0,0 +1,51 @@
+<template>
+<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340">
+ <div class="beeadbfb">
+ <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
+ <div class="name">{{ reaction.replace('@.', '') }}</div>
+ </div>
+</MkTooltip>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTooltip from './ui/tooltip.vue';
+import XReactionIcon from './reaction-icon.vue';
+
+export default defineComponent({
+ components: {
+ MkTooltip,
+ XReactionIcon,
+ },
+ props: {
+ reaction: {
+ type: String,
+ required: true,
+ },
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ source: {
+ required: true,
+ }
+ },
+ emits: ['closed'],
+})
+</script>
+
+<style lang="scss" scoped>
+.beeadbfb {
+ text-align: center;
+
+ > .icon {
+ display: block;
+ width: 60px;
+ margin: 0 auto;
+ }
+
+ > .name {
+ font-size: 0.9em;
+ }
+}
+</style>
diff --git a/packages/client/src/components/reactions-viewer.details.vue b/packages/client/src/components/reactions-viewer.details.vue
new file mode 100644
index 0000000000..7c49bd1d9c
--- /dev/null
+++ b/packages/client/src/components/reactions-viewer.details.vue
@@ -0,0 +1,91 @@
+<template>
+<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340">
+ <div class="bqxuuuey">
+ <div class="reaction">
+ <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
+ <div class="name">{{ reaction.replace('@.', '') }}</div>
+ </div>
+ <div class="users">
+ <template v-if="users.length <= 10">
+ <b v-for="u in users" :key="u.id" style="margin-right: 12px;">
+ <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
+ <MkUserName :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;">
+ <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
+ <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/>
+ </b>
+ <span slot="omitted">+{{ count - 10 }}</span>
+ </template>
+ </div>
+ </div>
+</MkTooltip>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTooltip from './ui/tooltip.vue';
+import XReactionIcon from './reaction-icon.vue';
+
+export default defineComponent({
+ components: {
+ MkTooltip,
+ XReactionIcon
+ },
+ props: {
+ reaction: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ source: {
+ required: true,
+ }
+ },
+ emits: ['closed'],
+})
+</script>
+
+<style lang="scss" scoped>
+.bqxuuuey {
+ display: flex;
+
+ > .reaction {
+ flex: 1;
+ max-width: 100px;
+ text-align: center;
+
+ > .icon {
+ display: block;
+ width: 60px;
+ margin: 0 auto;
+ }
+
+ > .name {
+ font-size: 0.9em;
+ }
+ }
+
+ > .users {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.9em;
+ border-left: solid 0.5px var(--divider);
+ padding-left: 10px;
+ margin-left: 10px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue
new file mode 100644
index 0000000000..47a3bb9720
--- /dev/null
+++ b/packages/client/src/components/reactions-viewer.reaction.vue
@@ -0,0 +1,183 @@
+<template>
+<button
+ class="hkzvhatu _button"
+ :class="{ reacted: note.myReaction == reaction, canToggle }"
+ @click="toggleReaction(reaction)"
+ v-if="count > 0"
+ @touchstart.passive="onMouseover"
+ @mouseover="onMouseover"
+ @mouseleave="onMouseleave"
+ @touchend="onMouseleave"
+ ref="reaction"
+ v-particle="canToggle"
+>
+ <XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/>
+ <span>{{ count }}</span>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import XDetails from '@/components/reactions-viewer.details.vue';
+import XReactionIcon from '@/components/reaction-icon.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XReactionIcon
+ },
+ props: {
+ reaction: {
+ type: String,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ isInitial: {
+ type: Boolean,
+ required: true,
+ },
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ close: null,
+ detailsTimeoutId: null,
+ isHovering: false
+ };
+ },
+ computed: {
+ canToggle(): boolean {
+ return !this.reaction.match(/@\w/) && this.$i;
+ },
+ },
+ watch: {
+ count(newCount, oldCount) {
+ if (oldCount < newCount) this.anime();
+ if (this.close != null) this.openDetails();
+ },
+ },
+ mounted() {
+ if (!this.isInitial) this.anime();
+ },
+ methods: {
+ toggleReaction() {
+ if (!this.canToggle) return;
+
+ const oldReaction = this.note.myReaction;
+ if (oldReaction) {
+ os.api('notes/reactions/delete', {
+ noteId: this.note.id
+ }).then(() => {
+ if (oldReaction !== this.reaction) {
+ os.api('notes/reactions/create', {
+ noteId: this.note.id,
+ reaction: this.reaction
+ });
+ }
+ });
+ } else {
+ os.api('notes/reactions/create', {
+ noteId: this.note.id,
+ reaction: this.reaction
+ });
+ }
+ },
+ onMouseover() {
+ if (this.isHovering) return;
+ this.isHovering = true;
+ this.detailsTimeoutId = setTimeout(this.openDetails, 300);
+ },
+ onMouseleave() {
+ if (!this.isHovering) return;
+ this.isHovering = false;
+ clearTimeout(this.detailsTimeoutId);
+ this.closeDetails();
+ },
+ openDetails() {
+ os.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;
+
+ const showing = ref(true);
+ os.popup(XDetails, {
+ showing,
+ reaction: this.reaction,
+ emojis: this.note.emojis,
+ users,
+ count: this.count,
+ source: this.$refs.reaction
+ }, {}, 'closed');
+
+ this.close = () => {
+ showing.value = false;
+ };
+ });
+ },
+ closeDetails() {
+ if (this.close != null) {
+ this.close();
+ this.close = null;
+ }
+ },
+ anime() {
+ if (document.hidden) return;
+
+ // TODO
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hkzvhatu {
+ display: inline-block;
+ height: 32px;
+ margin: 2px;
+ padding: 0 6px;
+ border-radius: 4px;
+
+ &.canToggle {
+ background: rgba(0, 0, 0, 0.05);
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.1);
+ }
+ }
+
+ &:not(.canToggle) {
+ cursor: default;
+ }
+
+ &.reacted {
+ background: var(--accent);
+
+ &:hover {
+ background: var(--accent);
+ }
+
+ > span {
+ color: var(--fgOnAccent);
+ }
+ }
+
+ > span {
+ font-size: 0.9em;
+ line-height: 32px;
+ margin: 0 0 0 4px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/reactions-viewer.vue b/packages/client/src/components/reactions-viewer.vue
new file mode 100644
index 0000000000..94a0318734
--- /dev/null
+++ b/packages/client/src/components/reactions-viewer.vue
@@ -0,0 +1,48 @@
+<template>
+<div class="tdflqwzn" :class="{ isMe }">
+ <XReaction 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 { defineComponent } from 'vue';
+import XReaction from './reactions-viewer.reaction.vue';
+
+export default defineComponent({
+ components: {
+ XReaction
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ },
+ data() {
+ return {
+ initialReactions: new Set(Object.keys(this.note.reactions))
+ };
+ },
+ computed: {
+ isMe(): boolean {
+ return this.$i && this.$i.id === this.note.userId;
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.tdflqwzn {
+ margin: 4px -2px 0 -2px;
+
+ &:empty {
+ display: none;
+ }
+
+ &.isMe {
+ > span {
+ cursor: default !important;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/remote-caution.vue b/packages/client/src/components/remote-caution.vue
new file mode 100644
index 0000000000..c496ea8f48
--- /dev/null
+++ b/packages/client/src/components/remote-caution.vue
@@ -0,0 +1,35 @@
+<template>
+<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ href: {
+ type: String,
+ required: true
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jmgmzlwq {
+ font-size: 0.8em;
+ padding: 16px;
+ background: var(--infoWarnBg);
+ color: var(--infoWarnFg);
+
+ > a {
+ margin-left: 4px;
+ color: var(--accent);
+ }
+}
+</style>
diff --git a/packages/client/src/components/sample.vue b/packages/client/src/components/sample.vue
new file mode 100644
index 0000000000..ba6c682c44
--- /dev/null
+++ b/packages/client/src/components/sample.vue
@@ -0,0 +1,116 @@
+<template>
+<div class="_card">
+ <div class="_content">
+ <MkInput v-model="text">
+ <template #label>Text</template>
+ </MkInput>
+ <MkSwitch v-model="flag">
+ <span>Switch is now {{ flag ? 'on' : 'off' }}</span>
+ </MkSwitch>
+ <div style="margin: 32px 0;">
+ <MkRadio v-model="radio" value="misskey">Misskey</MkRadio>
+ <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio>
+ <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
+ </div>
+ <MkButton inline>This is</MkButton>
+ <MkButton inline primary>the button</MkButton>
+ </div>
+ <div class="_content" style="pointer-events: none;">
+ <Mfm :text="mfm"/>
+ </div>
+ <div class="_content">
+ <MkButton inline primary @click="openMenu">Open menu</MkButton>
+ <MkButton inline primary @click="openDialog">Open dialog</MkButton>
+ <MkButton inline primary @click="openForm">Open form</MkButton>
+ <MkButton inline primary @click="openDrive">Open drive</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as config from '@/config';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSwitch,
+ MkTextarea,
+ MkRadio,
+ },
+
+ data() {
+ return {
+ text: '',
+ flag: true,
+ radio: 'misskey',
+ mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`
+ }
+ },
+
+ methods: {
+ async openDialog() {
+ os.dialog({
+ type: 'warning',
+ title: 'Oh my Aichan',
+ text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ });
+ },
+
+ async openForm() {
+ os.form('Example form', {
+ foo: {
+ type: 'boolean',
+ default: true,
+ label: 'This is a boolean property'
+ },
+ bar: {
+ type: 'number',
+ default: 300,
+ label: 'This is a number property'
+ },
+ baz: {
+ type: 'string',
+ default: 'Misskey makes you happy.',
+ label: 'This is a string property'
+ },
+ });
+ },
+
+ async openDrive() {
+ os.selectDriveFile();
+ },
+
+ async selectUser() {
+ os.selectUser();
+ },
+
+ async openMenu(ev) {
+ os.popupMenu([{
+ type: 'label',
+ text: 'Fruits'
+ }, {
+ text: 'Create some apples',
+ action: () => {},
+ }, {
+ text: 'Read some oranges',
+ action: () => {},
+ }, {
+ text: 'Update some melons',
+ action: () => {},
+ }, null, {
+ text: 'Delete some bananas',
+ danger: true,
+ action: () => {},
+ }], ev.currentTarget || ev.target);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/components/signin-dialog.vue b/packages/client/src/components/signin-dialog.vue
new file mode 100644
index 0000000000..2edd10f539
--- /dev/null
+++ b/packages/client/src/components/signin-dialog.vue
@@ -0,0 +1,42 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ :height="400"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.login }}</template>
+
+ <MkSignin :auto-set="autoSet" @login="onLogin"/>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkSignin from './signin.vue';
+
+export default defineComponent({
+ components: {
+ MkSignin,
+ XModalWindow,
+ },
+
+ props: {
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ methods: {
+ onLogin(res) {
+ this.$emit('done', res);
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue
new file mode 100644
index 0000000000..68bbd5368e
--- /dev/null
+++ b/packages/client/src/components/signin.vue
@@ -0,0 +1,240 @@
+<template>
+<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
+ <div class="auth _section _formRoot">
+ <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
+ <div class="normal-signin" v-if="!totpLogin">
+ <MkInput class="_formBlock" v-model="username" :placeholder="$ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:modelValue="onUsernameChange" data-cy-signin-username>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="password" :placeholder="$ts.password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required data-cy-signin-password>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <template #caption><button class="_textButton" @click="resetPassword" type="button">{{ $ts.forgotPassword }}</button></template>
+ </MkInput>
+ <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton>
+ </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>{{ $ts.tapSecurityKey }}</p>
+ <MkButton @click="queryKey" v-if="!queryingKey">
+ {{ $ts.retry }}
+ </MkButton>
+ </div>
+ <div class="or-hr" v-if="user && user.securityKeys">
+ <p class="or-msg">{{ $ts.or }}</p>
+ </div>
+ <div class="twofa-group totp-group">
+ <p style="margin-bottom:0;">{{ $ts.twoStepAuthentication }}</p>
+ <MkInput v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required>
+ <template #label>{{ $ts.password }}</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ </MkInput>
+ <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
+ <template #label>{{ $ts.token }}</template>
+ <template #prefix><i class="fas fa-gavel"></i></template>
+ </MkInput>
+ <MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton>
+ </div>
+ </div>
+ </div>
+ <div class="social _section">
+ <a class="_borderButton _gap" v-if="meta && meta.enableTwitterIntegration" :href="`${apiUrl}/signin/twitter`"><i class="fab fa-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a>
+ <a class="_borderButton _gap" v-if="meta && meta.enableGithubIntegration" :href="`${apiUrl}/signin/github`"><i class="fab fa-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a>
+ <a class="_borderButton _gap" v-if="meta && meta.enableDiscordIntegration" :href="`${apiUrl}/signin/discord`"><i class="fab fa-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a>
+ </div>
+</form>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import { apiUrl, host } from '@/config';
+import { byteify, hexify } from '@/scripts/2fa';
+import * as os from '@/os';
+import { login } from '@/account';
+import { showSuspendedDialog } from '../scripts/show-suspended-dialog';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ },
+
+ props: {
+ withAvatar: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['login'],
+
+ data() {
+ return {
+ signing: false,
+ user: null,
+ username: '',
+ password: '',
+ token: '',
+ apiUrl,
+ host: toUnicode(host),
+ totpLogin: false,
+ credential: null,
+ challengeData: null,
+ queryingKey: false,
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+ },
+
+ methods: {
+ onUsernameChange() {
+ os.api('users/show', {
+ username: this.username
+ }).then(user => {
+ this.user = user;
+ }, () => {
+ this.user = null;
+ });
+ },
+
+ onLogin(res) {
+ if (this.autoSet) {
+ return login(res.i);
+ } else {
+ return;
+ }
+ },
+
+ queryKey() {
+ this.queryingKey = true;
+ return navigator.credentials.get({
+ publicKey: {
+ challenge: byteify(this.challengeData.challenge, 'base64'),
+ allowCredentials: this.challengeData.securityKeys.map(key => ({
+ id: byteify(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 os.api('signin', {
+ username: this.username,
+ password: this.password,
+ signature: hexify(credential.response.signature),
+ authenticatorData: hexify(credential.response.authenticatorData),
+ clientDataJSON: hexify(credential.response.clientDataJSON),
+ credentialId: credential.id,
+ challengeId: this.challengeData.challengeId
+ });
+ }).then(res => {
+ this.$emit('login', res);
+ return this.onLogin(res);
+ }).catch(err => {
+ if (err === null) return;
+ os.dialog({
+ type: 'error',
+ text: this.$ts.signinFailed
+ });
+ this.signing = false;
+ });
+ },
+
+ onSubmit() {
+ this.signing = true;
+ if (!this.totpLogin && this.user && this.user.twoFactorEnabled) {
+ if (window.PublicKeyCredential && this.user.securityKeys) {
+ os.api('signin', {
+ username: this.username,
+ password: this.password
+ }).then(res => {
+ this.totpLogin = true;
+ this.signing = false;
+ this.challengeData = res;
+ return this.queryKey();
+ }).catch(this.loginFailed);
+ } else {
+ this.totpLogin = true;
+ this.signing = false;
+ }
+ } else {
+ os.api('signin', {
+ username: this.username,
+ password: this.password,
+ token: this.user && this.user.twoFactorEnabled ? this.token : undefined
+ }).then(res => {
+ this.$emit('login', res);
+ this.onLogin(res);
+ }).catch(this.loginFailed);
+ }
+ },
+
+ loginFailed(err) {
+ switch (err.id) {
+ case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
+ os.dialog({
+ type: 'error',
+ title: this.$ts.loginFailed,
+ text: this.$ts.noSuchUser
+ });
+ break;
+ }
+ case 'e03a5f46-d309-4865-9b69-56282d94e1eb': {
+ showSuspendedDialog();
+ break;
+ }
+ default: {
+ os.dialog({
+ type: 'error',
+ title: this.$ts.loginFailed,
+ text: JSON.stringify(err)
+ });
+ }
+ }
+
+ this.challengeData = null;
+ this.totpLogin = false;
+ this.signing = false;
+ },
+
+ resetPassword() {
+ os.popup(import('@/components/forgot-password.vue'), {}, {
+ }, 'closed');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.eppvobhk {
+ > .auth {
+ > .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/packages/client/src/components/signup-dialog.vue b/packages/client/src/components/signup-dialog.vue
new file mode 100644
index 0000000000..30fe3bf7d3
--- /dev/null
+++ b/packages/client/src/components/signup-dialog.vue
@@ -0,0 +1,50 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="366"
+ :height="500"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.signup }}</template>
+
+ <div class="_monolithic_">
+ <div class="_section">
+ <XSignup :auto-set="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import XSignup from './signup.vue';
+
+export default defineComponent({
+ components: {
+ XSignup,
+ XModalWindow,
+ },
+
+ props: {
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ methods: {
+ onSignup(res) {
+ this.$emit('done', res);
+ this.$refs.dialog.close();
+ },
+
+ onSignupEmailPending() {
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue
new file mode 100644
index 0000000000..621f30486f
--- /dev/null
+++ b/packages/client/src/components/signup.vue
@@ -0,0 +1,268 @@
+<template>
+<form class="qlvuhzng _formRoot" @submit.prevent="onSubmit" :autocomplete="Math.random()">
+ <template v-if="meta">
+ <MkInput class="_formBlock" v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required>
+ <template #label>{{ $ts.invitationCode }}</template>
+ <template #prefix><i class="fas fa-key"></i></template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeUsername" data-cy-signup-username>
+ <template #label>{{ $ts.username }} <div class="_button _help" v-tooltip:dialog="$ts.usernameInfo"><i class="far fa-question-circle"></i></div></template>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ <template #caption>
+ <span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
+ <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
+ <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
+ <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
+ <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span>
+ <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span>
+ <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-if="meta.emailRequiredForSignup" class="_formBlock" v-model="email" :debounce="true" type="email" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeEmail" data-cy-signup-email>
+ <template #label>{{ $ts.emailAddress }} <div class="_button _help" v-tooltip:dialog="$ts._signup.emailAddressInfo"><i class="far fa-question-circle"></i></div></template>
+ <template #prefix><i class="fas fa-envelope"></i></template>
+ <template #caption>
+ <span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
+ <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
+ <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span>
+ <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span>
+ <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span>
+ <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span>
+ <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</span>
+ <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
+ <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
+ </template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password>
+ <template #label>{{ $ts.password }}</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span>
+ <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span>
+ <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span>
+ </template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePasswordRetype" data-cy-signup-password-retype>
+ <template #label>{{ $ts.password }} ({{ $ts.retype }})</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span>
+ <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span>
+ </template>
+ </MkInput>
+ <label v-if="meta.tosUrl" class="_formBlock tou">
+ <input type="checkbox" v-model="ToSAgreement">
+ <I18n :src="$ts.agreeTo">
+ <template #0>
+ <a :href="meta.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a>
+ </template>
+ </I18n>
+ </label>
+ <captcha v-if="meta.enableHcaptcha" class="_formBlock captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/>
+ <captcha v-if="meta.enableRecaptcha" class="_formBlock captcha" provider="recaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/>
+ <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton>
+ </template>
+</form>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+const getPasswordStrength = require('syuilo-password-strength');
+import { toUnicode } from 'punycode/';
+import { host, url } from '@/config';
+import MkButton from './ui/button.vue';
+import MkInput from './form/input.vue';
+import MkSwitch from './form/switch.vue';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSwitch,
+ captcha: defineAsyncComponent(() => import('./captcha.vue')),
+ },
+
+ props: {
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['signup'],
+
+ data() {
+ return {
+ host: toUnicode(host),
+ username: '',
+ password: '',
+ retypedPassword: '',
+ invitationCode: '',
+ email: '',
+ url,
+ usernameState: null,
+ emailState: null,
+ passwordStrength: '',
+ passwordRetypeState: null,
+ submitting: false,
+ ToSAgreement: false,
+ hCaptchaResponse: null,
+ reCaptchaResponse: null,
+ }
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+
+ shouldDisableSubmitting(): boolean {
+ return this.submitting ||
+ this.meta.tosUrl && !this.ToSAgreement ||
+ this.meta.enableHcaptcha && !this.hCaptchaResponse ||
+ this.meta.enableRecaptcha && !this.reCaptchaResponse ||
+ this.passwordRetypeState == 'not-match';
+ },
+
+ shouldShowProfileUrl(): boolean {
+ return (this.username != '' &&
+ this.usernameState != 'invalid-format' &&
+ this.usernameState != 'min-range' &&
+ this.usernameState != 'max-range');
+ }
+ },
+
+ 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';
+
+ os.api('username/available', {
+ username: this.username
+ }).then(result => {
+ this.usernameState = result.available ? 'ok' : 'unavailable';
+ }).catch(err => {
+ this.usernameState = 'error';
+ });
+ },
+
+ onChangeEmail() {
+ if (this.email == '') {
+ this.emailState = null;
+ return;
+ }
+
+ this.emailState = 'wait';
+
+ os.api('email-address/available', {
+ emailAddress: this.email
+ }).then(result => {
+ this.emailState = result.available ? 'ok' :
+ result.reason === 'used' ? 'unavailable:used' :
+ result.reason === 'format' ? 'unavailable:format' :
+ result.reason === 'disposable' ? 'unavailable:disposable' :
+ result.reason === 'mx' ? 'unavailable:mx' :
+ result.reason === 'smtp' ? 'unavailable:smtp' :
+ 'unavailable';
+ }).catch(err => {
+ this.emailState = '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;
+
+ os.api('signup', {
+ username: this.username,
+ password: this.password,
+ emailAddress: this.email,
+ invitationCode: this.invitationCode,
+ 'hcaptcha-response': this.hCaptchaResponse,
+ 'g-recaptcha-response': this.reCaptchaResponse,
+ }).then(() => {
+ if (this.meta.emailRequiredForSignup) {
+ os.dialog({
+ type: 'success',
+ title: this.$ts._signup.almostThere,
+ text: this.$t('_signup.emailSent', { email: this.email }),
+ });
+ this.$emit('signupEmailPending');
+ } else {
+ os.api('signin', {
+ username: this.username,
+ password: this.password
+ }).then(res => {
+ this.$emit('signup', res);
+
+ if (this.autoSet) {
+ login(res.i);
+ }
+ });
+ }
+ }).catch(() => {
+ this.submitting = false;
+ this.$refs.hcaptcha?.reset?.();
+ this.$refs.recaptcha?.reset?.();
+
+ os.dialog({
+ type: 'error',
+ text: this.$ts.somethingHappened
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qlvuhzng {
+ .captcha {
+ margin: 16px 0;
+ }
+
+ > .tou {
+ display: block;
+ margin: 16px 0;
+ cursor: pointer;
+ }
+}
+</style>
diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue
new file mode 100644
index 0000000000..3aaf03995d
--- /dev/null
+++ b/packages/client/src/components/sparkle.vue
@@ -0,0 +1,179 @@
+<template>
+<span class="mk-sparkle">
+ <span ref="content">
+ <slot></slot>
+ </span>
+ <canvas ref="canvas"></canvas>
+</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+const sprite = new Image();
+sprite.src = '/client-assets/sparkle-spritesheet.png';
+
+export default defineComponent({
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ speed: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ sprites: [0,6,13,20],
+ particles: [],
+ anim: null,
+ ctx: null,
+ };
+ },
+ methods: {
+ createSparkles(w, h, count) {
+ var holder = [];
+
+ for (var i = 0; i < count; i++) {
+
+ const color = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6);
+
+ holder[i] = {
+ position: {
+ x: Math.floor(Math.random() * w),
+ y: Math.floor(Math.random() * h)
+ },
+ style: this.sprites[ Math.floor(Math.random() * 4) ],
+ delta: {
+ x: Math.floor(Math.random() * 1000) - 500,
+ y: Math.floor(Math.random() * 1000) - 500
+ },
+ color: color,
+ opacity: Math.random(),
+ };
+
+ }
+
+ return holder;
+ },
+ draw(time) {
+ this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height);
+ this.ctx.beginPath();
+
+ const particleSize = Math.floor(this.fontSize / 2);
+ this.particles.forEach((particle) => {
+ var modulus = Math.floor(Math.random()*7);
+
+ if (Math.floor(time) % modulus === 0) {
+ particle.style = this.sprites[ Math.floor(Math.random()*4) ];
+ }
+
+ this.ctx.save();
+ this.ctx.globalAlpha = particle.opacity;
+ this.ctx.drawImage(sprite, particle.style, 0, 7, 7, particle.position.x, particle.position.y, particleSize, particleSize);
+
+ this.ctx.globalCompositeOperation = "source-atop";
+ this.ctx.globalAlpha = 0.5;
+ this.ctx.fillStyle = particle.color;
+ this.ctx.fillRect(particle.position.x, particle.position.y, particleSize, particleSize);
+
+ this.ctx.restore();
+ });
+ this.ctx.stroke();
+ },
+ tick() {
+ this.anim = window.requestAnimationFrame((time) => {
+ if (!this.$refs.canvas) {
+ return;
+ }
+ this.particles.forEach((particle) => {
+ if (!particle) {
+ return;
+ }
+ var randX = Math.random() > Math.random() * 2;
+ var randY = Math.random() > Math.random() * 3;
+
+ if (randX) {
+ particle.position.x += (particle.delta.x * this.speed) / 1500;
+ }
+
+ if (!randY) {
+ particle.position.y -= (particle.delta.y * this.speed) / 800;
+ }
+
+ if( particle.position.x > this.$refs.canvas.width ) {
+ particle.position.x = -7;
+ } else if (particle.position.x < -7) {
+ particle.position.x = this.$refs.canvas.width;
+ }
+
+ if (particle.position.y > this.$refs.canvas.height) {
+ particle.position.y = -7;
+ particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width);
+ } else if (particle.position.y < -7) {
+ particle.position.y = this.$refs.canvas.height;
+ particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width);
+ }
+
+ particle.opacity -= 0.005;
+
+ if (particle.opacity <= 0) {
+ particle.opacity = 1;
+ }
+ });
+
+ this.draw(time);
+
+ this.tick();
+ });
+ },
+ resize() {
+ if (this.$refs.content) {
+ const contentRect = this.$refs.content.getBoundingClientRect();
+ this.fontSize = parseFloat(getComputedStyle(this.$refs.content).fontSize);
+ const padding = this.fontSize * 0.2;
+
+ this.$refs.canvas.width = parseInt(contentRect.width + padding);
+ this.$refs.canvas.height = parseInt(contentRect.height + padding);
+
+ this.particles = this.createSparkles(this.$refs.canvas.width, this.$refs.canvas.height, this.count);
+ }
+ },
+ },
+ mounted() {
+ this.ctx = this.$refs.canvas.getContext('2d');
+
+ new ResizeObserver(this.resize).observe(this.$refs.content);
+
+ this.resize();
+ this.tick();
+ },
+ updated() {
+ this.resize();
+ },
+ destroyed() {
+ window.cancelAnimationFrame(this.anim);
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-sparkle {
+ position: relative;
+ display: inline-block;
+
+ > span {
+ display: inline-block;
+ }
+
+ > canvas {
+ position: absolute;
+ top: -0.1em;
+ left: -0.1em;
+ pointer-events: none;
+ }
+}
+</style>
diff --git a/packages/client/src/components/sub-note-content.vue b/packages/client/src/components/sub-note-content.vue
new file mode 100644
index 0000000000..3f03f021cd
--- /dev/null
+++ b/packages/client/src/components/sub-note-content.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="wrmlmaau">
+ <div class="body">
+ <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+ <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
+ <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+ <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+ </div>
+ <details v-if="note.files.length > 0">
+ <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
+ <XMediaList :media-list="note.files"/>
+ </details>
+ <details v-if="note.poll">
+ <summary>{{ $ts.poll }}</summary>
+ <XPoll :note="note"/>
+ </details>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XPoll from './poll.vue';
+import XMediaList from './media-list.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XPoll,
+ XMediaList,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ };
+ }
+});
+</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/packages/client/src/components/tab.vue b/packages/client/src/components/tab.vue
new file mode 100644
index 0000000000..c629727358
--- /dev/null
+++ b/packages/client/src/components/tab.vue
@@ -0,0 +1,73 @@
+<script lang="ts">
+import { defineComponent, h, resolveDirective, withDirectives } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ required: true,
+ },
+ },
+ render() {
+ const options = this.$slots.default();
+
+ return withDirectives(h('div', {
+ class: 'pxhvhrfw',
+ }, options.map(option => withDirectives(h('button', {
+ class: ['_button', { active: this.modelValue === option.props.value }],
+ key: option.key,
+ disabled: this.modelValue === option.props.value,
+ onClick: () => {
+ this.$emit('update:modelValue', option.props.value);
+ }
+ }, option.children), [
+ [resolveDirective('click-anime')]
+ ]))), [
+ [resolveDirective('size'), { max: [500] }]
+ ]);
+ }
+});
+</script>
+
+<style lang="scss">
+.pxhvhrfw {
+ display: flex;
+ font-size: 90%;
+
+ > button {
+ flex: 1;
+ padding: 10px 8px;
+ border-radius: var(--radius);
+
+ &:disabled {
+ opacity: 1 !important;
+ cursor: default;
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--accentedBg);
+ }
+
+ &:not(.active):hover {
+ color: var(--fgHighlighted);
+ background: var(--panelHighlight);
+ }
+
+ &:not(:first-child) {
+ margin-left: 8px;
+ }
+
+ > .icon {
+ margin-right: 6px;
+ }
+ }
+
+ &.max-width_500px {
+ font-size: 80%;
+
+ > button {
+ padding: 11px 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/taskmanager.api-window.vue b/packages/client/src/components/taskmanager.api-window.vue
new file mode 100644
index 0000000000..6ec4da3a59
--- /dev/null
+++ b/packages/client/src/components/taskmanager.api-window.vue
@@ -0,0 +1,72 @@
+<template>
+<XWindow ref="window"
+ :initial-width="370"
+ :initial-height="450"
+ :can-resize="true"
+ @close="$refs.window.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>Req Viewer</template>
+
+ <div class="rlkneywz">
+ <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);">
+ <option value="req">Request</option>
+ <option value="res">Response</option>
+ </MkTab>
+
+ <code v-if="tab === 'req'" class="_monospace">{{ reqStr }}</code>
+ <code v-if="tab === 'res'" class="_monospace">{{ resStr }}</code>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import XWindow from '@/components/ui/window.vue';
+import MkTab from '@/components/tab.vue';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ MkTab,
+ },
+
+ props: {
+ req: {
+ required: true,
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ tab: 'req',
+ reqStr: JSON5.stringify(this.req.req, null, '\t'),
+ resStr: JSON5.stringify(this.req.res, null, '\t'),
+ }
+ },
+
+ methods: {
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rlkneywz {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > code {
+ display: block;
+ flex: 1;
+ padding: 8px;
+ overflow: auto;
+ font-size: 0.9em;
+ tab-size: 2;
+ white-space: pre;
+ }
+}
+</style>
diff --git a/packages/client/src/components/taskmanager.vue b/packages/client/src/components/taskmanager.vue
new file mode 100644
index 0000000000..6efbf286e6
--- /dev/null
+++ b/packages/client/src/components/taskmanager.vue
@@ -0,0 +1,233 @@
+<template>
+<XWindow ref="window" :initial-width="650" :initial-height="420" :can-resize="true" @closed="$emit('closed')">
+ <template #header>
+ <i class="fas fa-terminal" style="margin-right: 0.5em;"></i>Task Manager
+ </template>
+ <div class="qljqmnzj _monospace">
+ <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);">
+ <option value="windows">Windows</option>
+ <option value="stream">Stream</option>
+ <option value="streamPool">Stream (Pool)</option>
+ <option value="api">API</option>
+ </MkTab>
+
+ <div class="content">
+ <div v-if="tab === 'windows'" class="windows" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Component</div>
+ <div>Action</div>
+ </div>
+ <div v-for="p in popups">
+ <div>#{{ p.id }}</div>
+ <div>{{ p.component.name ? p.component.name : '<anonymous>' }}</div>
+ <div><button class="_textButton" @click="killPopup(p)">Kill</button></div>
+ </div>
+ </div>
+ <div v-if="tab === 'stream'" class="stream" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Ch</div>
+ <div>Handle</div>
+ <div>In</div>
+ <div>Out</div>
+ </div>
+ <div v-for="c in connections">
+ <div>#{{ c.id }}</div>
+ <div>{{ c.channel }}</div>
+ <div v-if="c.users !== null">(shared)<span v-if="c.name">{{ ' ' + c.name }}</span></div>
+ <div v-else>{{ c.name ? c.name : '<anonymous>' }}</div>
+ <div>{{ c.in }}</div>
+ <div>{{ c.out }}</div>
+ </div>
+ </div>
+ <div v-if="tab === 'streamPool'" class="streamPool" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Ch</div>
+ <div>Users</div>
+ </div>
+ <div v-for="p in pools">
+ <div>#{{ p.id }}</div>
+ <div>{{ p.channel }}</div>
+ <div>{{ p.users }}</div>
+ </div>
+ </div>
+ <div v-if="tab === 'api'" class="api" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Endpoint</div>
+ <div>State</div>
+ </div>
+ <div v-for="req in apiRequests" @click="showReq(req)">
+ <div>#{{ req.id }}</div>
+ <div>{{ req.endpoint }}</div>
+ <div class="state" :class="req.state">{{ req.state }}</div>
+ </div>
+ </div>
+ </div>
+
+ <footer>
+ <div><span class="label">Windows</span>{{ popups.length }}</div>
+ <div><span class="label">Stream</span>{{ connections.length }}</div>
+ <div><span class="label">Stream (Pool)</span>{{ pools.length }}</div>
+ </footer>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw, onBeforeUnmount, ref, shallowRef } from 'vue';
+import XWindow from '@/components/ui/window.vue';
+import MkTab from '@/components/tab.vue';
+import MkButton from '@/components/ui/button.vue';
+import follow from '@/directives/follow-append';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ MkTab,
+ MkButton,
+ },
+
+ directives: {
+ follow
+ },
+
+ props: {
+ },
+
+ emits: ['closed'],
+
+ setup() {
+ const connections = shallowRef([]);
+ const pools = shallowRef([]);
+ const refreshStreamInfo = () => {
+ console.log(os.stream.sharedConnectionPools, os.stream.sharedConnections, os.stream.nonSharedConnections);
+ const conn = os.stream.sharedConnections.map(c => ({
+ id: c.id, name: c.name, channel: c.channel, users: c.pool.users, in: c.inCount, out: c.outCount,
+ })).concat(os.stream.nonSharedConnections.map(c => ({
+ id: c.id, name: c.name, channel: c.channel, users: null, in: c.inCount, out: c.outCount,
+ })));
+ conn.sort((a, b) => (a.id > b.id) ? 1 : -1);
+ connections.value = conn;
+ pools.value = os.stream.sharedConnectionPools;
+ };
+ const interval = setInterval(refreshStreamInfo, 1000);
+ onBeforeUnmount(() => {
+ clearInterval(interval);
+ });
+
+ const killPopup = p => {
+ os.popups.value = os.popups.value.filter(x => x !== p);
+ };
+
+ const showReq = req => {
+ os.popup(import('./taskmanager.api-window.vue'), {
+ req: req
+ }, {
+ }, 'closed');
+ };
+
+ return {
+ tab: ref('stream'),
+ popups: os.popups,
+ apiRequests: os.apiRequests,
+ connections,
+ pools,
+ killPopup,
+ showReq,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.qljqmnzj {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > .content {
+ flex: 1;
+ overflow: auto;
+
+ > div {
+ display: table;
+ width: 100%;
+ padding: 16px;
+ box-sizing: border-box;
+
+ > div {
+ display: table-row;
+
+ &:nth-child(even) {
+ //background: rgba(0, 0, 0, 0.1);
+ }
+
+ &.header {
+ opacity: 0.7;
+ }
+
+ > div {
+ display: table-cell;
+ white-space: nowrap;
+
+ &:not(:last-child) {
+ padding-right: 8px;
+ }
+ }
+ }
+
+ &.api {
+ > div {
+ &:not(.header) {
+ cursor: pointer;
+
+ &:hover {
+ color: var(--accent);
+ }
+ }
+
+ > .state {
+ &.pending {
+ color: var(--warn);
+ }
+
+ &.success {
+ color: var(--success);
+ }
+
+ &.failed {
+ color: var(--error);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ > footer {
+ display: flex;
+ width: 100%;
+ padding: 8px 16px;
+ box-sizing: border-box;
+ border-top: solid 0.5px var(--divider);
+ font-size: 0.9em;
+
+ > div {
+ flex: 1;
+
+ > .label {
+ opacity: 0.7;
+ margin-right: 0.5em;
+
+ &:after {
+ content: ":";
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/timeline.vue
new file mode 100644
index 0000000000..fa7f4e7f4d
--- /dev/null
+++ b/packages/client/src/components/timeline.vue
@@ -0,0 +1,183 @@
+<template>
+<XNotes :no-gap="!$store.state.showGapBetweenNotesInTimeline" ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XNotes from './notes.vue';
+import * as os from '@/os';
+import * as sound from '@/scripts/sound';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ provide() {
+ return {
+ inChannel: this.src === 'channel'
+ };
+ },
+
+ props: {
+ src: {
+ type: String,
+ required: true
+ },
+ list: {
+ type: String,
+ required: false
+ },
+ antenna: {
+ type: String,
+ required: false
+ },
+ channel: {
+ type: String,
+ required: false
+ },
+ sound: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['note', 'queue', 'before', 'after'],
+
+ data() {
+ return {
+ connection: null,
+ connection2: null,
+ pagination: null,
+ baseQuery: {
+ includeMyRenotes: this.$store.state.showMyRenotes,
+ includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
+ includeLocalRenotes: this.$store.state.showLocalRenotes
+ },
+ query: {},
+ date: null
+ };
+ },
+
+ created() {
+ const prepend = note => {
+ (this.$refs.tl as any).prepend(note);
+
+ this.$emit('note');
+
+ if (this.sound) {
+ sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
+ }
+ };
+
+ const onUserAdded = () => {
+ (this.$refs.tl as any).reload();
+ };
+
+ const onUserRemoved = () => {
+ (this.$refs.tl as any).reload();
+ };
+
+ const onChangeFollowing = () => {
+ if (!this.$refs.tl.backed) {
+ this.$refs.tl.reload();
+ }
+ };
+
+ let endpoint;
+
+ if (this.src == 'antenna') {
+ endpoint = 'antennas/notes';
+ this.query = {
+ antennaId: this.antenna
+ };
+ this.connection = markRaw(os.stream.useChannel('antenna', {
+ antennaId: this.antenna
+ }));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'home') {
+ endpoint = 'notes/timeline';
+ this.connection = markRaw(os.stream.useChannel('homeTimeline'));
+ this.connection.on('note', prepend);
+
+ this.connection2 = markRaw(os.stream.useChannel('main'));
+ this.connection2.on('follow', onChangeFollowing);
+ this.connection2.on('unfollow', onChangeFollowing);
+ } else if (this.src == 'local') {
+ endpoint = 'notes/local-timeline';
+ this.connection = markRaw(os.stream.useChannel('localTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'social') {
+ endpoint = 'notes/hybrid-timeline';
+ this.connection = markRaw(os.stream.useChannel('hybridTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'global') {
+ endpoint = 'notes/global-timeline';
+ this.connection = markRaw(os.stream.useChannel('globalTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'mentions') {
+ endpoint = 'notes/mentions';
+ this.connection = markRaw(os.stream.useChannel('main'));
+ this.connection.on('mention', prepend);
+ } else if (this.src == 'directs') {
+ endpoint = 'notes/mentions';
+ this.query = {
+ visibility: 'specified'
+ };
+ const onNote = note => {
+ if (note.visibility == 'specified') {
+ prepend(note);
+ }
+ };
+ this.connection = markRaw(os.stream.useChannel('main'));
+ this.connection.on('mention', onNote);
+ } else if (this.src == 'list') {
+ endpoint = 'notes/user-list-timeline';
+ this.query = {
+ listId: this.list
+ };
+ this.connection = markRaw(os.stream.useChannel('userList', {
+ listId: this.list
+ }));
+ this.connection.on('note', prepend);
+ this.connection.on('userAdded', onUserAdded);
+ this.connection.on('userRemoved', onUserRemoved);
+ } else if (this.src == 'channel') {
+ endpoint = 'channels/timeline';
+ this.query = {
+ channelId: this.channel
+ };
+ this.connection = markRaw(os.stream.useChannel('channel', {
+ channelId: this.channel
+ }));
+ this.connection.on('note', prepend);
+ }
+
+ this.pagination = {
+ endpoint: endpoint,
+ limit: 10,
+ params: init => ({
+ untilDate: this.date?.getTime(),
+ ...this.baseQuery, ...this.query
+ })
+ };
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ if (this.connection2) this.connection2.dispose();
+ },
+
+ methods: {
+ focus() {
+ this.$refs.tl.focus();
+ },
+
+ timetravel(date?: Date) {
+ this.date = date;
+ this.$refs.tl.reload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue
new file mode 100644
index 0000000000..fb0de68092
--- /dev/null
+++ b/packages/client/src/components/toast.vue
@@ -0,0 +1,73 @@
+<template>
+<div class="mk-toast">
+ <transition name="notification-slide" appear @after-leave="$emit('closed')">
+ <XNotification :notification="notification" class="notification _acrylic" v-if="showing"/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNotification from './notification.vue';
+
+export default defineComponent({
+ components: {
+ XNotification
+ },
+ props: {
+ notification: {
+ type: Object,
+ required: true
+ }
+ },
+ emits: ['closed'],
+ data() {
+ return {
+ showing: true
+ };
+ },
+ mounted() {
+ setTimeout(() => {
+ this.showing = 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-from, .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%;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ overflow: hidden;
+ }
+}
+</style>
diff --git a/packages/client/src/components/token-generate-window.vue b/packages/client/src/components/token-generate-window.vue
new file mode 100644
index 0000000000..bf5775d4d8
--- /dev/null
+++ b/packages/client/src/components/token-generate-window.vue
@@ -0,0 +1,117 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="400"
+ :height="450"
+ :with-ok-button="true"
+ :ok-button-disabled="false"
+ :can-close="false"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+ @ok="ok()"
+>
+ <template #header>{{ title || $ts.generateAccessToken }}</template>
+ <div v-if="information" class="_section">
+ <MkInfo warn>{{ information }}</MkInfo>
+ </div>
+ <div class="_section">
+ <MkInput v-model="name">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+ </div>
+ <div class="_section">
+ <div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div>
+ <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton>
+ <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton>
+ <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { permissions } from 'misskey-js';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkInput from './form/input.vue';
+import MkTextarea from './form/textarea.vue';
+import MkSwitch from './form/switch.vue';
+import MkButton from './ui/button.vue';
+import MkInfo from './ui/info.vue';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkInput,
+ MkTextarea,
+ MkSwitch,
+ MkButton,
+ MkInfo,
+ },
+
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: null
+ },
+ information: {
+ type: String,
+ required: false,
+ default: null
+ },
+ initialName: {
+ type: String,
+ required: false,
+ default: null
+ },
+ initialPermissions: {
+ type: Array,
+ required: false,
+ default: null
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ name: this.initialName,
+ permissions: {},
+ kinds: permissions
+ };
+ },
+
+ created() {
+ if (this.initialPermissions) {
+ for (const kind of this.initialPermissions) {
+ this.permissions[kind] = true;
+ }
+ } else {
+ for (const kind of this.kinds) {
+ this.permissions[kind] = false;
+ }
+ }
+ },
+
+ methods: {
+ ok() {
+ this.$emit('done', {
+ name: this.name,
+ permissions: Object.keys(this.permissions).filter(p => this.permissions[p])
+ });
+ this.$refs.dialog.close();
+ },
+
+ disableAll() {
+ for (const p in this.permissions) {
+ this.permissions[p] = false;
+ }
+ },
+
+ enableAll() {
+ for (const p in this.permissions) {
+ this.permissions[p] = true;
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue
new file mode 100644
index 0000000000..b5f4547c84
--- /dev/null
+++ b/packages/client/src/components/ui/button.vue
@@ -0,0 +1,262 @@
+<template>
+<button v-if="!link" class="bghgjjyj _button"
+ :class="{ inline, primary, gradate, danger, rounded, full }"
+ :type="type"
+ @click="$emit('click', $event)"
+ @mousedown="onMousedown"
+>
+ <div ref="ripples" class="ripples"></div>
+ <div class="content">
+ <slot></slot>
+ </div>
+</button>
+<MkA v-else class="bghgjjyj _button"
+ :class="{ inline, primary, gradate, danger, rounded, full }"
+ :to="to"
+ @mousedown="onMousedown"
+>
+ <div ref="ripples" class="ripples"></div>
+ <div class="content">
+ <slot></slot>
+ </div>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ type: {
+ type: String,
+ required: false
+ },
+ primary: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ gradate: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ rounded: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ link: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ to: {
+ type: String,
+ required: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ wait: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ danger: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ emits: ['click'],
+ 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;
+ z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
+ display: block;
+ min-width: 100px;
+ width: max-content;
+ padding: 8px 14px;
+ text-align: center;
+ font-weight: normal;
+ font-size: 0.8em;
+ line-height: 22px;
+ box-shadow: none;
+ text-decoration: none;
+ background: var(--buttonBg);
+ border-radius: 4px;
+ overflow: clip;
+ box-sizing: border-box;
+ transition: background 0.1s ease;
+
+ &:not(:disabled):hover {
+ background: var(--buttonHoverBg);
+ }
+
+ &:not(:disabled):active {
+ background: var(--buttonHoverBg);
+ }
+
+ &.full {
+ width: 100%;
+ }
+
+ &.rounded {
+ border-radius: 999px;
+ }
+
+ &.primary {
+ font-weight: bold;
+ color: var(--fgOnAccent) !important;
+ background: var(--accent);
+
+ &:not(:disabled):hover {
+ background: var(--X8);
+ }
+
+ &:not(:disabled):active {
+ background: var(--X8);
+ }
+ }
+
+ &.gradate {
+ font-weight: bold;
+ color: var(--fgOnAccent) !important;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+
+ &:not(:disabled):hover {
+ background: linear-gradient(90deg, var(--X8), var(--X8));
+ }
+
+ &:not(:disabled):active {
+ background: linear-gradient(90deg, var(--X8), var(--X8));
+ }
+ }
+
+ &.danger {
+ color: #ff2a2a;
+
+ &.primary {
+ color: #fff;
+ background: #ff2a2a;
+
+ &:not(:disabled):hover {
+ background: #ff4242;
+ }
+
+ &:not(:disabled):active {
+ background: #d42e2e;
+ }
+ }
+ }
+
+ &:disabled {
+ opacity: 0.7;
+ }
+
+ &:focus-visible {
+ outline: solid 2px var(--focus);
+ outline-offset: 2px;
+ }
+
+ &.inline {
+ display: inline-block;
+ width: auto;
+ min-width: 100px;
+ }
+
+ > .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/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue
new file mode 100644
index 0000000000..14673dfcd7
--- /dev/null
+++ b/packages/client/src/components/ui/container.vue
@@ -0,0 +1,262 @@
+<template>
+<div class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }">
+ <header v-if="showHeader" ref="header">
+ <div class="title"><slot name="header"></slot></div>
+ <div class="sub">
+ <slot name="func"></slot>
+ <button class="_button" v-if="foldable" @click="() => showBody = !showBody">
+ <template v-if="showBody"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ </div>
+ </header>
+ <transition name="container-toggle"
+ @enter="enter"
+ @after-enter="afterEnter"
+ @leave="leave"
+ @after-leave="afterLeave"
+ >
+ <div v-show="showBody" class="content" :class="{ omitted }" ref="content">
+ <slot></slot>
+ <button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }">
+ <span>{{ $ts.showMore }}</span>
+ </button>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ showHeader: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ thin: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ naked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ foldable: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ scrollable: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ maxHeight: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ },
+ data() {
+ return {
+ showBody: this.expanded,
+ omitted: null,
+ ignoreOmit: false,
+ };
+ },
+ mounted() {
+ this.$watch('showBody', showBody => {
+ const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
+ this.$el.style.minHeight = `${headerHeight}px`;
+ if (showBody) {
+ this.$el.style.flexBasis = `auto`;
+ } else {
+ this.$el.style.flexBasis = `${headerHeight}px`;
+ }
+ }, {
+ immediate: true
+ });
+
+ this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
+
+ const calcOmit = () => {
+ if (this.omitted || this.ignoreOmit || this.maxHeight == null) return;
+ const height = this.$refs.content.offsetHeight;
+ this.omitted = height > this.maxHeight;
+ };
+
+ calcOmit();
+ new ResizeObserver((entries, observer) => {
+ calcOmit();
+ }).observe(this.$refs.content);
+ },
+ methods: {
+ toggleContent(show: boolean) {
+ if (!this.foldable) return;
+ this.showBody = show;
+ },
+
+ enter(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = 0;
+ el.offsetHeight; // reflow
+ el.style.height = elementHeight + 'px';
+ },
+ afterEnter(el) {
+ el.style.height = null;
+ },
+ leave(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = elementHeight + 'px';
+ el.offsetHeight; // reflow
+ el.style.height = 0;
+ },
+ afterLeave(el) {
+ el.style.height = null;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.container-toggle-enter-active, .container-toggle-leave-active {
+ overflow-y: hidden;
+ transition: opacity 0.5s, height 0.5s !important;
+}
+.container-toggle-enter-from {
+ opacity: 0;
+}
+.container-toggle-leave-to {
+ opacity: 0;
+}
+
+.ukygtjoj {
+ position: relative;
+ overflow: clip;
+
+ &.naked {
+ background: transparent !important;
+ box-shadow: none !important;
+ }
+
+ &.scrollable {
+ display: flex;
+ flex-direction: column;
+
+ > .content {
+ overflow: auto;
+ }
+ }
+
+ > header {
+ position: sticky;
+ top: var(--stickyTop, 0px);
+ left: 0;
+ color: var(--panelHeaderFg);
+ background: var(--panelHeaderBg);
+ border-bottom: solid 0.5px var(--panelHeaderDivider);
+ z-index: 2;
+ line-height: 1.4em;
+
+ > .title {
+ margin: 0;
+ padding: 12px 16px;
+
+ > ::v-deep(i) {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .sub {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+ height: 100%;
+
+ > ::v-deep(button) {
+ width: 42px;
+ height: 100%;
+ }
+ }
+ }
+
+ > .content {
+ --stickyTop: 0px;
+
+ &.omitted {
+ position: relative;
+ max-height: var(--maxHeight);
+ overflow: hidden;
+
+ > .fade {
+ display: block;
+ position: absolute;
+ z-index: 10;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+
+ > span {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+ }
+
+ &:hover {
+ > span {
+ background: var(--panelHighlight);
+ }
+ }
+ }
+ }
+ }
+
+ &.max-width_380px, &.thin {
+ > header {
+ > .title {
+ padding: 8px 10px;
+ font-size: 0.9em;
+ }
+ }
+
+ > .content {
+ }
+ }
+}
+
+._forceContainerFull_ .ukygtjoj {
+ > header {
+ > .title {
+ padding: 12px 16px !important;
+ }
+ }
+}
+
+._forceContainerFull_.ukygtjoj {
+ > header {
+ > .title {
+ padding: 12px 16px !important;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/context-menu.vue b/packages/client/src/components/ui/context-menu.vue
new file mode 100644
index 0000000000..561099cbe0
--- /dev/null
+++ b/packages/client/src/components/ui/context-menu.vue
@@ -0,0 +1,97 @@
+<template>
+<transition :name="$store.state.animation ? 'fade' : ''" appear>
+ <div class="nvlagfpb" @contextmenu.prevent.stop="() => {}">
+ <MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import contains from '@/scripts/contains';
+import MkMenu from './menu.vue';
+
+export default defineComponent({
+ components: {
+ MkMenu,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ ev: {
+ required: true
+ },
+ viaKeyboard: {
+ type: Boolean,
+ required: false
+ },
+ },
+ emits: ['closed'],
+ computed: {
+ keymap(): any {
+ return {
+ 'esc': () => this.$emit('closed'),
+ };
+ },
+ },
+ mounted() {
+ let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+ let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+
+ const width = this.$el.offsetWidth;
+ const height = this.$el.offsetHeight;
+
+ if (left + width - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - width + window.pageXOffset;
+ }
+
+ if (top + height - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - height + window.pageYOffset;
+ }
+
+ if (top < 0) {
+ top = 0;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ this.$el.style.top = top + 'px';
+ this.$el.style.left = left + 'px';
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.addEventListener('mousedown', this.onMousedown);
+ }
+ },
+ beforeUnmount() {
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.removeEventListener('mousedown', this.onMousedown);
+ }
+ },
+ methods: {
+ onMousedown(e) {
+ if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.nvlagfpb {
+ position: absolute;
+ z-index: 65535;
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
+ transform-origin: left top;
+}
+
+.fade-enter-from, .fade-leave-to {
+ opacity: 0;
+ transform: scale(0.9);
+}
+</style>
diff --git a/packages/client/src/components/ui/folder.vue b/packages/client/src/components/ui/folder.vue
new file mode 100644
index 0000000000..3997421d08
--- /dev/null
+++ b/packages/client/src/components/ui/folder.vue
@@ -0,0 +1,156 @@
+<template>
+<div class="ssazuxis" v-size="{ max: [500] }">
+ <header @click="showBody = !showBody" class="_button" :style="{ background: bg }">
+ <div class="title"><slot name="header"></slot></div>
+ <div class="divider"></div>
+ <button class="_button">
+ <template v-if="showBody"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ </header>
+ <transition name="folder-toggle"
+ @enter="enter"
+ @after-enter="afterEnter"
+ @leave="leave"
+ @after-leave="afterLeave"
+ >
+ <div v-show="showBody">
+ <slot></slot>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+
+const localStoragePrefix = 'ui:folder:';
+
+export default defineComponent({
+ props: {
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ persistKey: {
+ type: String,
+ required: false,
+ default: null
+ },
+ },
+ data() {
+ return {
+ bg: null,
+ showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded,
+ };
+ },
+ watch: {
+ showBody() {
+ if (this.persistKey) {
+ localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f');
+ }
+ }
+ },
+ mounted() {
+ function getParentBg(el: Element | null): string {
+ if (el == null || el.tagName === 'BODY') return 'var(--bg)';
+ const bg = el.style.background || el.style.backgroundColor;
+ if (bg) {
+ return bg;
+ } else {
+ return getParentBg(el.parentElement);
+ }
+ }
+ const rawBg = getParentBg(this.$el);
+ const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ bg.setAlpha(0.85);
+ this.bg = bg.toRgbString();
+ },
+ methods: {
+ toggleContent(show: boolean) {
+ this.showBody = show;
+ },
+
+ enter(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = 0;
+ el.offsetHeight; // reflow
+ el.style.height = elementHeight + 'px';
+ },
+ afterEnter(el) {
+ el.style.height = null;
+ },
+ leave(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = elementHeight + 'px';
+ el.offsetHeight; // reflow
+ el.style.height = 0;
+ },
+ afterLeave(el) {
+ el.style.height = null;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.folder-toggle-enter-active, .folder-toggle-leave-active {
+ overflow-y: hidden;
+ transition: opacity 0.5s, height 0.5s !important;
+}
+.folder-toggle-enter-from {
+ opacity: 0;
+}
+.folder-toggle-leave-to {
+ opacity: 0;
+}
+
+.ssazuxis {
+ position: relative;
+
+ > header {
+ display: flex;
+ position: relative;
+ z-index: 10;
+ position: sticky;
+ top: var(--stickyTop, 0px);
+ padding: var(--x-padding);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(20px));
+
+ > .title {
+ margin: 0;
+ padding: 12px 16px 12px 0;
+
+ > i {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .divider {
+ flex: 1;
+ margin: auto;
+ height: 1px;
+ background: var(--divider);
+ }
+
+ > button {
+ padding: 12px 0 12px 16px;
+ }
+ }
+
+ &.max-width_500px {
+ > header {
+ > .title {
+ padding: 8px 10px 8px 0;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue
new file mode 100644
index 0000000000..6b075cb440
--- /dev/null
+++ b/packages/client/src/components/ui/hr.vue
@@ -0,0 +1,16 @@
+<template>
+<div class="evrzpitu"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';import * as os from '@/os';
+
+export default defineComponent({});
+</script>
+
+<style lang="scss" scoped>
+.evrzpitu
+ margin 16px 0
+ border-bottom solid var(--lineWidth) var(--faceDivider)
+
+</style>
diff --git a/packages/client/src/components/ui/info.vue b/packages/client/src/components/ui/info.vue
new file mode 100644
index 0000000000..8f5986baf7
--- /dev/null
+++ b/packages/client/src/components/ui/info.vue
@@ -0,0 +1,45 @@
+<template>
+<div class="fpezltsf" :class="{ warn }">
+ <i v-if="warn" class="fas fa-exclamation-triangle"></i>
+ <i v-else class="fas fa-info-circle"></i>
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ warn: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fpezltsf {
+ padding: 16px;
+ font-size: 90%;
+ background: var(--infoBg);
+ color: var(--infoFg);
+ border-radius: var(--radius);
+
+ &.warn {
+ background: var(--infoWarnBg);
+ color: var(--infoWarnFg);
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue
new file mode 100644
index 0000000000..5938fb00a1
--- /dev/null
+++ b/packages/client/src/components/ui/menu.vue
@@ -0,0 +1,278 @@
+<template>
+<div class="rrevdjwt" :class="{ center: align === 'center' }"
+ :style="{ width: width ? width + 'px' : null }"
+ ref="items"
+ @contextmenu.self="e => e.preventDefault()"
+ v-hotkey="keymap"
+>
+ <template v-for="(item, i) in _items">
+ <div v-if="item === null" class="divider"></div>
+ <span v-else-if="item.type === 'label'" class="label item">
+ <span>{{ item.text }}</span>
+ </span>
+ <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item">
+ <span><MkEllipsis/></span>
+ </span>
+ <MkA v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item">
+ <i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
+ <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+ <span>{{ item.text }}</span>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </MkA>
+ <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item">
+ <i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
+ <span>{{ item.text }}</span>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </a>
+ <button v-else-if="item.type === 'user'" @click="clicked(item.action, $event)" :tabindex="i" class="_button item">
+ <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ <button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active">
+ <i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
+ <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+ <span>{{ item.text }}</span>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ </template>
+ <span v-if="_items.length === 0" class="none item">
+ <span>{{ $ts.none }}</span>
+ </span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, unref } from 'vue';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import contains from '@/scripts/contains';
+
+export default defineComponent({
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ viaKeyboard: {
+ type: Boolean,
+ required: false
+ },
+ align: {
+ type: String,
+ requried: false
+ },
+ width: {
+ type: Number,
+ required: false
+ },
+ },
+ emits: ['close'],
+ data() {
+ return {
+ _items: [],
+ };
+ },
+ computed: {
+ keymap(): any {
+ return {
+ 'up|k|shift+tab': this.focusUp,
+ 'down|j|tab': this.focusDown,
+ 'esc': this.close,
+ };
+ },
+ },
+ watch: {
+ items: {
+ handler() {
+ const items = ref(unref(this.items).filter(item => item !== undefined));
+
+ for (let i = 0; i < items.value.length; i++) {
+ const item = items.value[i];
+
+ if (item && item.then) { // if item is Promise
+ items.value[i] = { type: 'pending' };
+ item.then(actualItem => {
+ items.value[i] = actualItem;
+ });
+ }
+ }
+
+ this._items = items;
+ },
+ immediate: true
+ }
+ },
+ mounted() {
+ if (this.viaKeyboard) {
+ this.$nextTick(() => {
+ focusNext(this.$refs.items.children[0], true, false);
+ });
+ }
+
+ if (this.contextmenuEvent) {
+ this.$el.style.top = this.contextmenuEvent.pageY + 'px';
+ this.$el.style.left = this.contextmenuEvent.pageX + 'px';
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.addEventListener('mousedown', this.onMousedown);
+ }
+ }
+ },
+ beforeUnmount() {
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.removeEventListener('mousedown', this.onMousedown);
+ }
+ },
+ methods: {
+ clicked(fn, ev) {
+ fn(ev);
+ this.close();
+ },
+ close() {
+ this.$emit('close');
+ },
+ focusUp() {
+ focusPrev(document.activeElement);
+ },
+ focusDown() {
+ focusNext(document.activeElement);
+ },
+ onMousedown(e) {
+ if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rrevdjwt {
+ padding: 8px 0;
+ min-width: 200px;
+ max-height: 90vh;
+ overflow: auto;
+
+ &.center {
+ > .item {
+ text-align: center;
+ }
+ }
+
+ > .item {
+ display: block;
+ position: relative;
+ padding: 8px 18px;
+ width: 100%;
+ box-sizing: border-box;
+ white-space: nowrap;
+ font-size: 0.9em;
+ line-height: 20px;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: calc(100% - 16px);
+ height: 100%;
+ border-radius: 6px;
+ }
+
+ > * {
+ position: relative;
+ }
+
+ &.danger {
+ color: #ff2a2a;
+
+ &:hover {
+ color: #fff;
+
+ &:before {
+ background: #ff4242;
+ }
+ }
+
+ &:active {
+ color: #fff;
+
+ &:before {
+ background: #d42e2e;
+ }
+ }
+ }
+
+ &.active {
+ color: var(--fgOnAccent);
+ opacity: 1;
+
+ &:before {
+ background: var(--accent);
+ }
+ }
+
+ &:not(:disabled):hover {
+ color: var(--accent);
+ text-decoration: none;
+
+ &:before {
+ background: var(--accentedBg);
+ }
+ }
+
+ &:not(:active):focus-visible {
+ box-shadow: 0 0 0 2px var(--focus) inset;
+ }
+
+ &.label {
+ pointer-events: none;
+ font-size: 0.7em;
+ padding-bottom: 4px;
+
+ > span {
+ opacity: 0.7;
+ }
+ }
+
+ &.pending {
+ pointer-events: none;
+ opacity: 0.7;
+ }
+
+ &.none {
+ pointer-events: none;
+ opacity: 0.7;
+ }
+
+ > i {
+ margin-right: 5px;
+ width: 20px;
+ }
+
+ > .avatar {
+ margin-right: 5px;
+ width: 20px;
+ height: 20px;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 5px;
+ left: 13px;
+ color: var(--indicator);
+ font-size: 12px;
+ animation: blink 1s infinite;
+ }
+ }
+
+ > .divider {
+ margin: 8px 0;
+ height: 1px;
+ background: var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue
new file mode 100644
index 0000000000..da98192b87
--- /dev/null
+++ b/packages/client/src/components/ui/modal-window.vue
@@ -0,0 +1,148 @@
+<template>
+<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
+ <div class="ebkgoccj _window _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }">
+ <div class="header">
+ <button class="_button" v-if="withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button>
+ <span class="title">
+ <slot name="header"></slot>
+ </span>
+ <button class="_button" v-if="!withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button>
+ <button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><i class="fas fa-check"></i></button>
+ </div>
+ <div class="body" v-if="padding">
+ <div class="_section">
+ <slot></slot>
+ </div>
+ </div>
+ <div class="body" v-else>
+ <slot></slot>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from './modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal
+ },
+ props: {
+ withOkButton: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ okButtonDisabled: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ padding: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ width: {
+ type: Number,
+ required: false,
+ default: 400
+ },
+ height: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ canClose: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ scroll: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+
+ emits: ['click', 'close', 'closed', 'ok'],
+
+ data() {
+ return {
+ };
+ },
+
+ 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 {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ contain: content;
+
+ --root-margin: 24px;
+
+ @media (max-width: 500px) {
+ --root-margin: 16px;
+ }
+
+ > .header {
+ $height: 58px;
+ $height-narrow: 42px;
+ display: flex;
+ flex-shrink: 0;
+ box-shadow: 0px 1px var(--divider);
+
+ > 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;
+ }
+ }
+
+ > button + .title {
+ padding-left: 0;
+ }
+ }
+
+ > .body {
+ overflow: auto;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue
new file mode 100644
index 0000000000..33fcdb687f
--- /dev/null
+++ b/packages/client/src/components/ui/modal.vue
@@ -0,0 +1,292 @@
+<template>
+<transition :name="$store.state.animation ? popup ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? popup ? 500 : 300 : 0" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered">
+ <div v-show="manualShowing != null ? manualShowing : showing" class="qzhlnise" :class="{ front }" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
+ <div class="bg _modalBg" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
+ <div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content">
+ <slot></slot>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+function getFixedContainer(el: Element | null): Element | null {
+ if (el == null || el.tagName === 'BODY') return null;
+ const position = window.getComputedStyle(el).getPropertyValue('position');
+ if (position === 'fixed') {
+ return el;
+ } else {
+ return getFixedContainer(el.parentElement);
+ }
+}
+
+export default defineComponent({
+ provide: {
+ modal: true
+ },
+ props: {
+ manualShowing: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ srcCenter: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ required: false,
+ },
+ position: {
+ required: false
+ },
+ front: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+ emits: ['opening', 'click', 'esc', 'close', 'closed'],
+ data() {
+ return {
+ showing: true,
+ fixed: false,
+ transformOrigin: 'center',
+ contentClicking: false,
+ };
+ },
+ computed: {
+ keymap(): any {
+ return {
+ 'esc': () => this.$emit('esc'),
+ };
+ },
+ popup(): boolean {
+ return this.src != null;
+ }
+ },
+ mounted() {
+ this.$watch('src', () => {
+ this.fixed = getFixedContainer(this.src) != null;
+ this.$nextTick(() => {
+ this.align();
+ });
+ }, { immediate: true });
+
+ this.$nextTick(() => {
+ const popover = this.$refs.content as any;
+ new ResizeObserver((entries, observer) => {
+ this.align();
+ }).observe(popover);
+ });
+ },
+ methods: {
+ align() {
+ if (!this.popup) return;
+
+ const popover = this.$refs.content as any;
+
+ if (popover == null) return;
+
+ const rect = this.src.getBoundingClientRect();
+
+ const width = popover.offsetWidth;
+ const height = popover.offsetHeight;
+
+ let left;
+ let top;
+
+ if (this.srcCenter) {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2);
+ left = (x - (width / 2));
+ top = (y - (height / 2));
+ } else {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight;
+ left = (x - (width / 2));
+ top = y;
+ }
+
+ if (this.fixed) {
+ if (left + width > window.innerWidth) {
+ left = window.innerWidth - width;
+ }
+
+ if (top + height > window.innerHeight) {
+ top = window.innerHeight - height;
+ }
+ } else {
+ if (left + width - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - width + window.pageXOffset - 1;
+ }
+
+ if (top + height - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - height + window.pageYOffset - 1;
+ }
+ }
+
+ if (top < 0) {
+ top = 0;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) {
+ this.transformOrigin = 'center top';
+ } else {
+ this.transformOrigin = 'center';
+ }
+
+ popover.style.left = left + 'px';
+ popover.style.top = top + 'px';
+ },
+
+ childRendered() {
+ // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+ const content = this.$refs.content.children[0];
+ content.addEventListener('mousedown', e => {
+ this.contentClicking = true;
+ window.addEventListener('mouseup', e => {
+ // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+ setTimeout(() => {
+ this.contentClicking = false;
+ }, 100);
+ }, { passive: true, once: true });
+ }, { passive: true });
+ },
+
+ close() {
+ this.showing = false;
+ this.$emit('close');
+ },
+
+ onBgClick() {
+ if (this.contentClicking) return;
+ this.$emit('click');
+ },
+
+ onClosed() {
+ this.$emit('closed');
+ }
+ }
+});
+</script>
+
+<style lang="scss">
+.modal-popup-enter-active, .modal-popup-leave-active,
+.modal-enter-from, .modal-leave-to {
+ > .content {
+ transform-origin: var(--transformOrigin);
+ }
+}
+</style>
+
+<style lang="scss" scoped>
+.modal-enter-active, .modal-leave-active {
+ > .bg {
+ transition: opacity 0.3s !important;
+ }
+
+ > .content {
+ transition: opacity 0.3s, transform 0.3s !important;
+ }
+}
+.modal-enter-from, .modal-leave-to {
+ > .bg {
+ opacity: 0;
+ }
+
+ > .content {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+ }
+}
+
+.modal-popup-enter-active, .modal-popup-leave-active {
+ > .bg {
+ transition: opacity 0.3s !important;
+ }
+
+ > .content {
+ transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1) !important;
+ }
+}
+.modal-popup-enter-from, .modal-popup-leave-to {
+ > .bg {
+ opacity: 0;
+ }
+
+ > .content {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+ }
+}
+
+.qzhlnise {
+ > .bg {
+ z-index: 10000;
+ }
+
+ > .content:not(.popup) {
+ position: fixed;
+ z-index: 10000;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ padding: 32px;
+ // TODO: mask-imageはiOSだとやたら重い。なんとかしたい
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+ overflow: auto;
+ display: flex;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+ }
+
+ > ::v-deep(*) {
+ margin: auto;
+ }
+
+ &.top {
+ > ::v-deep(*) {
+ margin-top: 0;
+ }
+ }
+ }
+
+ > .content.popup {
+ position: absolute;
+ z-index: 10000;
+
+ &.fixed {
+ position: fixed;
+ }
+ }
+
+ &.front {
+ > .bg {
+ z-index: 20000;
+ }
+
+ > .content:not(.popup) {
+ z-index: 20000;
+ }
+
+ > .content.popup {
+ z-index: 20000;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue
new file mode 100644
index 0000000000..f6a457d88f
--- /dev/null
+++ b/packages/client/src/components/ui/pagination.vue
@@ -0,0 +1,69 @@
+<template>
+<transition name="fade" mode="out-in">
+ <MkLoading v-if="fetching"/>
+
+ <MkError v-else-if="error" @retry="init()"/>
+
+ <div class="empty" v-else-if="empty" key="_empty_">
+ <slot name="empty"></slot>
+ </div>
+
+ <div v-else class="cxiknjgy">
+ <slot :items="items"></slot>
+ <div class="more _gap" v-show="more" key="_more_">
+ <MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from './button.vue';
+import paging from '@/scripts/paging';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+
+ disableAutoLoad: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.cxiknjgy {
+ > .more > .button {
+ margin-left: auto;
+ margin-right: auto;
+ height: 48px;
+ min-width: 150px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/ui/popup-menu.vue
new file mode 100644
index 0000000000..3ff4c658b1
--- /dev/null
+++ b/packages/client/src/components/ui/popup-menu.vue
@@ -0,0 +1,42 @@
+<template>
+<MkPopup ref="popup" :src="src" @closed="$emit('closed')">
+ <MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/>
+</MkPopup>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPopup from './popup.vue';
+import MkMenu from './menu.vue';
+
+export default defineComponent({
+ components: {
+ MkPopup,
+ MkMenu,
+ },
+
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ align: {
+ type: String,
+ required: false
+ },
+ width: {
+ type: Number,
+ required: false
+ },
+ viaKeyboard: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ required: false
+ },
+ },
+
+ emits: ['close', 'closed'],
+});
+</script>
diff --git a/packages/client/src/components/ui/popup.vue b/packages/client/src/components/ui/popup.vue
new file mode 100644
index 0000000000..0fb1780cc5
--- /dev/null
+++ b/packages/client/src/components/ui/popup.vue
@@ -0,0 +1,213 @@
+<template>
+<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered">
+ <div v-show="manualShowing != null ? manualShowing : showing" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" ref="content" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
+ <slot></slot>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+function getFixedContainer(el: Element | null): Element | null {
+ if (el == null || el.tagName === 'BODY') return null;
+ const position = window.getComputedStyle(el).getPropertyValue('position');
+ if (position === 'fixed') {
+ return el;
+ } else {
+ return getFixedContainer(el.parentElement);
+ }
+}
+
+export default defineComponent({
+ props: {
+ manualShowing: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ srcCenter: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ type: Object as PropType<HTMLElement>,
+ required: false,
+ },
+ position: {
+ required: false
+ },
+ front: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['opening', 'click', 'esc', 'close', 'closed'],
+
+ data() {
+ return {
+ showing: true,
+ fixed: false,
+ transformOrigin: 'center',
+ contentClicking: false,
+ };
+ },
+
+ mounted() {
+ this.$watch('src', () => {
+ if (this.src) {
+ this.src.style.pointerEvents = 'none';
+ }
+ this.fixed = getFixedContainer(this.src) != null;
+ this.$nextTick(() => {
+ this.align();
+ });
+ }, { immediate: true });
+
+ this.$nextTick(() => {
+ const popover = this.$refs.content as any;
+ new ResizeObserver((entries, observer) => {
+ this.align();
+ }).observe(popover);
+ });
+
+ document.addEventListener('mousedown', this.onDocumentClick, { passive: true });
+ },
+
+ beforeUnmount() {
+ document.removeEventListener('mousedown', this.onDocumentClick);
+ },
+
+ methods: {
+ align() {
+ if (this.src == null) return;
+
+ const popover = this.$refs.content as any;
+
+ if (popover == null) return;
+
+ const rect = this.src.getBoundingClientRect();
+
+ const width = popover.offsetWidth;
+ const height = popover.offsetHeight;
+
+ let left;
+ let top;
+
+ if (this.srcCenter) {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2);
+ left = (x - (width / 2));
+ top = (y - (height / 2));
+ } else {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight;
+ left = (x - (width / 2));
+ top = y;
+ }
+
+ if (this.fixed) {
+ if (left + width > window.innerWidth) {
+ left = window.innerWidth - width;
+ }
+
+ if (top + height > window.innerHeight) {
+ top = window.innerHeight - height;
+ }
+ } else {
+ if (left + width - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - width + window.pageXOffset - 1;
+ }
+
+ if (top + height - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - height + window.pageYOffset - 1;
+ }
+ }
+
+ if (top < 0) {
+ top = 0;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) {
+ this.transformOrigin = 'center top';
+ } else {
+ this.transformOrigin = 'center';
+ }
+
+ popover.style.left = left + 'px';
+ popover.style.top = top + 'px';
+ },
+
+ childRendered() {
+ // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+ const content = this.$refs.content.children[0];
+ content.addEventListener('mousedown', e => {
+ this.contentClicking = true;
+ window.addEventListener('mouseup', e => {
+ // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+ setTimeout(() => {
+ this.contentClicking = false;
+ }, 100);
+ }, { passive: true, once: true });
+ }, { passive: true });
+ },
+
+ close() {
+ if (this.src) this.src.style.pointerEvents = 'auto';
+ this.showing = false;
+ this.$emit('close');
+ },
+
+ onClosed() {
+ this.$emit('closed');
+ },
+
+ onDocumentClick(ev) {
+ const flyoutElement = this.$refs.content;
+ let targetElement = ev.target;
+ do {
+ if (targetElement === flyoutElement) {
+ return;
+ }
+ targetElement = targetElement.parentNode;
+ } while (targetElement);
+ this.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.popup-menu-enter-active {
+ transform-origin: var(--transformOrigin);
+ transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
+}
+.popup-menu-leave-active {
+ transform-origin: var(--transformOrigin);
+ transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), transform 0.2s cubic-bezier(0.4, 0, 1, 1) !important;
+}
+.popup-menu-enter-from, .popup-menu-leave-to {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.ccczpooj {
+ position: absolute;
+ z-index: 10000;
+
+ &.fixed {
+ position: fixed;
+ }
+
+ &.front {
+ z-index: 20000;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/super-menu.vue b/packages/client/src/components/ui/super-menu.vue
new file mode 100644
index 0000000000..195cc57326
--- /dev/null
+++ b/packages/client/src/components/ui/super-menu.vue
@@ -0,0 +1,148 @@
+<template>
+<div class="rrevdjwu" :class="{ grid }">
+ <div class="group" v-for="group in def">
+ <div class="title" v-if="group.title">{{ group.title }}</div>
+
+ <div class="items">
+ <template v-for="(item, i) in group.items">
+ <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+ <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
+ <span class="text">{{ item.text }}</span>
+ </a>
+ <button v-else-if="item.type === 'button'" @click="ev => item.action(ev)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active">
+ <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
+ <span class="text">{{ item.text }}</span>
+ </button>
+ <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+ <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
+ <span class="text">{{ item.text }}</span>
+ </MkA>
+ </template>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, unref } from 'vue';
+
+export default defineComponent({
+ props: {
+ def: {
+ type: Array,
+ required: true
+ },
+ grid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.rrevdjwu {
+ > .group {
+ & + .group {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .title {
+ font-size: 0.9em;
+ opacity: 0.7;
+ margin: 0 0 8px 12px;
+ }
+
+ > .items {
+ > .item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 16px 10px 8px;
+ border-radius: 9px;
+ font-size: 0.9em;
+
+ &:hover {
+ text-decoration: none;
+ background: var(--panelHighlight);
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--accentedBg);
+ }
+
+ &.danger {
+ color: var(--error);
+ }
+
+ > .icon {
+ width: 32px;
+ margin-right: 2px;
+ flex-shrink: 0;
+ text-align: center;
+ opacity: 0.8;
+ }
+
+ > .text {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-right: 12px;
+ }
+
+ }
+ }
+ }
+
+ &.grid {
+ > .group {
+ & + .group {
+ padding-top: 0;
+ border-top: none;
+ }
+
+ margin-left: 0;
+ margin-right: 0;
+
+ > .title {
+ font-size: 1em;
+ opacity: 0.7;
+ margin: 0 0 8px 16px;
+ }
+
+ > .items {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ grid-gap: 8px;
+ padding: 0 16px;
+
+ > .item {
+ flex-direction: column;
+ padding: 18px 16px 16px 16px;
+ background: var(--panel);
+ border-radius: 8px;
+ text-align: center;
+
+ > .icon {
+ display: block;
+ margin-right: 0;
+ margin-bottom: 12px;
+ font-size: 1.5em;
+ }
+
+ > .text {
+ padding-right: 0;
+ width: 100%;
+ font-size: 0.8em;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue
new file mode 100644
index 0000000000..c003895c14
--- /dev/null
+++ b/packages/client/src/components/ui/tooltip.vue
@@ -0,0 +1,92 @@
+<template>
+<transition name="tooltip" appear @after-leave="$emit('closed')">
+ <div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content" :style="{ maxWidth: maxWidth + 'px' }">
+ <slot>{{ text }}</slot>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ showing: {
+ type: Boolean,
+ required: true,
+ },
+ source: {
+ required: true,
+ },
+ text: {
+ type: String,
+ required: false
+ },
+ maxWidth: {
+ type: Number,
+ required: false,
+ default: 250,
+ },
+ },
+
+ emits: ['closed'],
+
+ mounted() {
+ this.$nextTick(() => {
+ if (this.source == null) {
+ this.$emit('closed');
+ return;
+ }
+
+ const rect = this.source.getBoundingClientRect();
+
+ const contentWidth = this.$refs.content.offsetWidth;
+ const contentHeight = this.$refs.content.offsetHeight;
+
+ let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
+ let top = rect.top + window.pageYOffset - contentHeight;
+
+ left -= (this.$el.offsetWidth / 2);
+
+ if (left + contentWidth - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+ }
+
+ if (top - window.pageYOffset < 0) {
+ top = rect.top + window.pageYOffset + this.source.offsetHeight;
+ this.$refs.content.style.transformOrigin = 'center top';
+ }
+
+ this.$el.style.left = left + 'px';
+ this.$el.style.top = top + 'px';
+ });
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.tooltip-enter-active,
+.tooltip-leave-active {
+ opacity: 1;
+ transform: scale(1);
+ transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tooltip-enter-from,
+.tooltip-leave-active {
+ opacity: 0;
+ transform: scale(0.75);
+}
+
+.buebdbiu {
+ position: absolute;
+ z-index: 11000;
+ font-size: 0.8em;
+ padding: 8px 12px;
+ box-sizing: border-box;
+ text-align: center;
+ border-radius: 4px;
+ border: solid 0.5px var(--divider);
+ pointer-events: none;
+ transform-origin: center bottom;
+}
+</style>
diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue
new file mode 100644
index 0000000000..b7093b6641
--- /dev/null
+++ b/packages/client/src/components/ui/window.vue
@@ -0,0 +1,525 @@
+<template>
+<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')">
+ <div class="ebkgocck" :class="{ front }" v-if="showing">
+ <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
+ <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
+ <span class="left">
+ <slot name="headerLeft"></slot>
+ </span>
+ <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
+ <slot name="header"></slot>
+ </span>
+ <span class="right">
+ <slot name="headerRight"></slot>
+ <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button>
+ </span>
+ </div>
+ <div class="body" v-if="padding">
+ <div class="_section">
+ <slot></slot>
+ </div>
+ </div>
+ <div class="body" v-else>
+ <slot></slot>
+ </div>
+ </div>
+ <template v-if="canResize">
+ <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div>
+ <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div>
+ <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div>
+ <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div>
+ <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div>
+ <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div>
+ <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div>
+ <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
+ </template>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import contains from '@/scripts/contains';
+import * as os from '@/os';
+
+const minHeight = 50;
+const minWidth = 250;
+
+function dragListen(fn) {
+ window.addEventListener('mousemove', fn);
+ window.addEventListener('touchmove', fn);
+ window.addEventListener('mouseleave', dragClear.bind(null, fn));
+ window.addEventListener('mouseup', dragClear.bind(null, fn));
+ window.addEventListener('touchend', dragClear.bind(null, fn));
+}
+
+function dragClear(fn) {
+ window.removeEventListener('mousemove', fn);
+ window.removeEventListener('touchmove', fn);
+ window.removeEventListener('mouseleave', dragClear);
+ window.removeEventListener('mouseup', dragClear);
+ window.removeEventListener('touchend', dragClear);
+}
+
+export default defineComponent({
+ provide: {
+ inWindow: true
+ },
+
+ props: {
+ padding: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ initialWidth: {
+ type: Number,
+ required: false,
+ default: 400
+ },
+ initialHeight: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ canResize: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ closeButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ mini: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ front: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ contextmenu: {
+ type: Array,
+ required: false,
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ showing: true,
+ id: Math.random().toString(), // TODO: UUIDとかにする
+ };
+ },
+
+ mounted() {
+ if (this.initialWidth) this.applyTransformWidth(this.initialWidth);
+ if (this.initialHeight) this.applyTransformHeight(this.initialHeight);
+
+ this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2));
+ this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2));
+
+ os.windows.set(this.id, {
+ z: Number(document.defaultView.getComputedStyle(this.$el, null).zIndex)
+ });
+
+ // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする
+ this.top();
+
+ window.addEventListener('resize', this.onBrowserResize);
+ },
+
+ unmounted() {
+ os.windows.delete(this.id);
+ window.removeEventListener('resize', this.onBrowserResize);
+ },
+
+ methods: {
+ close() {
+ this.showing = false;
+ },
+
+ onKeydown(e) {
+ if (e.which === 27) { // Esc
+ e.preventDefault();
+ e.stopPropagation();
+ this.close();
+ }
+ },
+
+ onContextmenu(e) {
+ if (this.contextmenu) {
+ os.contextMenu(this.contextmenu, e);
+ }
+ },
+
+ // 最前面へ移動
+ top() {
+ let z = 0;
+ const ws = Array.from(os.windows.entries()).filter(([k, v]) => k !== this.id).map(([k, v]) => v);
+ for (const w of ws) {
+ if (w.z > z) z = w.z;
+ }
+ if (z > 0) {
+ (this.$el as any).style.zIndex = z + 1;
+ os.windows.set(this.id, {
+ z: z + 1
+ });
+ }
+ },
+
+ onBodyMousedown() {
+ this.top();
+ },
+
+ onHeaderMousedown(e) {
+ const main = this.$el as any;
+
+ if (!contains(main, document.activeElement)) main.focus();
+
+ const position = main.getBoundingClientRect();
+
+ const clickX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX;
+ const clickY = e.touches && e.touches.length > 0 ? e.touches[0].clientY : e.clientY;
+ const moveBaseX = clickX - position.left;
+ const moveBaseY = clickY - position.top;
+ const browserWidth = window.innerWidth;
+ const browserHeight = window.innerHeight;
+ const windowWidth = main.offsetWidth;
+ const windowHeight = main.offsetHeight;
+
+ // 動かした時
+ dragListen(me => {
+ const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX;
+ const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY;
+
+ let moveLeft = x - moveBaseX;
+ let moveTop = y - moveBaseY;
+
+ // 下はみ出し
+ if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight;
+
+ // 左はみ出し
+ if (moveLeft < 0) moveLeft = 0;
+
+ // 上はみ出し
+ if (moveTop < 0) moveTop = 0;
+
+ // 右はみ出し
+ if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth;
+
+ this.$el.style.left = moveLeft + 'px';
+ this.$el.style.top = moveTop + 'px';
+ });
+ },
+
+ // 上ハンドル掴み時
+ onTopHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientY;
+ const height = parseInt(getComputedStyle(main, '').height, 10);
+ const top = parseInt(getComputedStyle(main, '').top, 10);
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientY - base;
+ if (top + move > 0) {
+ if (height + -move > minHeight) {
+ this.applyTransformHeight(height + -move);
+ this.applyTransformTop(top + move);
+ } else { // 最小の高さより小さくなろうとした時
+ this.applyTransformHeight(minHeight);
+ this.applyTransformTop(top + (height - minHeight));
+ }
+ } else { // 上のはみ出し時
+ this.applyTransformHeight(top + height);
+ this.applyTransformTop(0);
+ }
+ });
+ },
+
+ // 右ハンドル掴み時
+ onRightHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientX;
+ const width = parseInt(getComputedStyle(main, '').width, 10);
+ const left = parseInt(getComputedStyle(main, '').left, 10);
+ const browserWidth = window.innerWidth;
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientX - base;
+ if (left + width + move < browserWidth) {
+ if (width + move > minWidth) {
+ this.applyTransformWidth(width + move);
+ } else { // 最小の幅より小さくなろうとした時
+ this.applyTransformWidth(minWidth);
+ }
+ } else { // 右のはみ出し時
+ this.applyTransformWidth(browserWidth - left);
+ }
+ });
+ },
+
+ // 下ハンドル掴み時
+ onBottomHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientY;
+ const height = parseInt(getComputedStyle(main, '').height, 10);
+ const top = parseInt(getComputedStyle(main, '').top, 10);
+ const browserHeight = window.innerHeight;
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientY - base;
+ if (top + height + move < browserHeight) {
+ if (height + move > minHeight) {
+ this.applyTransformHeight(height + move);
+ } else { // 最小の高さより小さくなろうとした時
+ this.applyTransformHeight(minHeight);
+ }
+ } else { // 下のはみ出し時
+ this.applyTransformHeight(browserHeight - top);
+ }
+ });
+ },
+
+ // 左ハンドル掴み時
+ onLeftHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientX;
+ const width = parseInt(getComputedStyle(main, '').width, 10);
+ const left = parseInt(getComputedStyle(main, '').left, 10);
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientX - base;
+ if (left + move > 0) {
+ if (width + -move > minWidth) {
+ this.applyTransformWidth(width + -move);
+ this.applyTransformLeft(left + move);
+ } else { // 最小の幅より小さくなろうとした時
+ this.applyTransformWidth(minWidth);
+ this.applyTransformLeft(left + (width - minWidth));
+ }
+ } else { // 左のはみ出し時
+ this.applyTransformWidth(left + width);
+ this.applyTransformLeft(0);
+ }
+ });
+ },
+
+ // 左上ハンドル掴み時
+ onTopLeftHandleMousedown(e) {
+ this.onTopHandleMousedown(e);
+ this.onLeftHandleMousedown(e);
+ },
+
+ // 右上ハンドル掴み時
+ onTopRightHandleMousedown(e) {
+ this.onTopHandleMousedown(e);
+ this.onRightHandleMousedown(e);
+ },
+
+ // 右下ハンドル掴み時
+ onBottomRightHandleMousedown(e) {
+ this.onBottomHandleMousedown(e);
+ this.onRightHandleMousedown(e);
+ },
+
+ // 左下ハンドル掴み時
+ onBottomLeftHandleMousedown(e) {
+ this.onBottomHandleMousedown(e);
+ this.onLeftHandleMousedown(e);
+ },
+
+ // 高さを適用
+ applyTransformHeight(height) {
+ if (height > window.innerHeight) height = window.innerHeight;
+ (this.$el as any).style.height = height + 'px';
+ },
+
+ // 幅を適用
+ applyTransformWidth(width) {
+ if (width > window.innerWidth) width = window.innerWidth;
+ (this.$el as any).style.width = width + 'px';
+ },
+
+ // Y座標を適用
+ applyTransformTop(top) {
+ (this.$el as any).style.top = top + 'px';
+ },
+
+ // X座標を適用
+ applyTransformLeft(left) {
+ (this.$el as any).style.left = left + 'px';
+ },
+
+ onBrowserResize() {
+ const main = this.$el as any;
+ const position = main.getBoundingClientRect();
+ const browserWidth = window.innerWidth;
+ const browserHeight = window.innerHeight;
+ const windowWidth = main.offsetWidth;
+ const windowHeight = main.offsetHeight;
+ if (position.left < 0) main.style.left = 0; // 左はみ出し
+ if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し
+ if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し
+ if (position.top < 0) main.style.top = 0; // 上はみ出し
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.window-enter-active, .window-leave-active {
+ transition: opacity 0.2s, transform 0.2s !important;
+}
+.window-enter-from, .window-leave-to {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.ebkgocck {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 10000; // mk-modalのと同じでなければならない
+
+ &.front {
+ z-index: 11000; // front指定の時は、mk-modalのよりも大きくなければならない
+ }
+
+ > .body {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ contain: content;
+ width: 100%;
+ height: 100%;
+
+ > .header {
+ --height: 50px;
+
+ &.mini {
+ --height: 38px;
+ }
+
+ display: flex;
+ position: relative;
+ z-index: 1;
+ flex-shrink: 0;
+ user-select: none;
+ height: var(--height);
+ border-bottom: solid 1px var(--divider);
+
+ > .left, > .right {
+ > ::v-deep(button) {
+ height: var(--height);
+ width: var(--height);
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+ }
+
+ > .title {
+ flex: 1;
+ position: relative;
+ line-height: var(--height);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+ cursor: move;
+ }
+ }
+
+ > .body {
+ flex: 1;
+ overflow: auto;
+ }
+ }
+
+ > .handle {
+ $size: 8px;
+
+ position: absolute;
+
+ &.top {
+ top: -($size);
+ left: 0;
+ width: 100%;
+ height: $size;
+ cursor: ns-resize;
+ }
+
+ &.right {
+ top: 0;
+ right: -($size);
+ width: $size;
+ height: 100%;
+ cursor: ew-resize;
+ }
+
+ &.bottom {
+ bottom: -($size);
+ left: 0;
+ width: 100%;
+ height: $size;
+ cursor: ns-resize;
+ }
+
+ &.left {
+ top: 0;
+ left: -($size);
+ width: $size;
+ height: 100%;
+ cursor: ew-resize;
+ }
+
+ &.top-left {
+ top: -($size);
+ left: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nwse-resize;
+ }
+
+ &.top-right {
+ top: -($size);
+ right: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nesw-resize;
+ }
+
+ &.bottom-right {
+ bottom: -($size);
+ right: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nwse-resize;
+ }
+
+ &.bottom-left {
+ bottom: -($size);
+ left: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nesw-resize;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/updated.vue b/packages/client/src/components/updated.vue
new file mode 100644
index 0000000000..c021c60669
--- /dev/null
+++ b/packages/client/src/components/updated.vue
@@ -0,0 +1,62 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="ewlycnyt">
+ <div class="title">{{ $ts.misskeyUpdated }}</div>
+ <div class="version">✨{{ version }}🚀</div>
+ <MkButton full @click="whatIsNew">{{ $ts.whatIsNew }}</MkButton>
+ <MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ $ts.gotIt }}</MkButton>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import MkButton from '@/components/ui/button.vue';
+import { version } from '@/config';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkButton,
+ },
+
+ data() {
+ return {
+ version: version,
+ };
+ },
+
+ methods: {
+ whatIsNew() {
+ this.$refs.modal.close();
+ window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ewlycnyt {
+ position: relative;
+ padding: 32px;
+ min-width: 320px;
+ max-width: 480px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ font-weight: bold;
+ }
+
+ > .version {
+ margin: 1em 0;
+ }
+
+ > .gotIt {
+ margin: 8px 0 0 0;
+ }
+}
+</style>
diff --git a/packages/client/src/components/url-preview-popup.vue b/packages/client/src/components/url-preview-popup.vue
new file mode 100644
index 0000000000..0a402f793f
--- /dev/null
+++ b/packages/client/src/components/url-preview-popup.vue
@@ -0,0 +1,60 @@
+<template>
+<div class="fgmtyycl" :style="{ top: top + 'px', left: left + 'px' }">
+ <transition name="zoom" @after-leave="$emit('closed')">
+ <MkUrlPreview class="_popup _shadow" :url="url" v-if="showing"/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkUrlPreview from './url-preview.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkUrlPreview
+ },
+
+ props: {
+ url: {
+ type: String,
+ required: true
+ },
+ source: {
+ required: true
+ },
+ showing: {
+ type: Boolean,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ u: null,
+ top: 0,
+ left: 0,
+ };
+ },
+
+ mounted() {
+ const rect = this.source.getBoundingClientRect();
+ const x = Math.max((rect.left + (this.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset;
+ const y = rect.top + this.source.offsetHeight + window.pageYOffset;
+
+ this.top = y;
+ this.left = x;
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fgmtyycl {
+ position: absolute;
+ z-index: 11000;
+ width: 500px;
+ max-width: calc(90vw - 12px);
+ pointer-events: none;
+}
+</style>
diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue
new file mode 100644
index 0000000000..0826ba5ccf
--- /dev/null
+++ b/packages/client/src/components/url-preview.vue
@@ -0,0 +1,334 @@
+<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="$ts.disablePlayer"><i class="fas fa-times"></i></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="tweetId && tweetExpanded" class="twitter" ref="twitter">
+ <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
+</div>
+<div v-else class="mk-url-preview" v-size="{ max: [400, 350] }">
+ <transition name="zoom" mode="out-in">
+ <component :is="self ? 'MkA' : 'a'" :class="{ compact }" :[attr]="self ? 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="$ts.enablePlayer"><i class="fas fa-play-circle"></i></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 class="expandTweet" v-if="tweetId">
+ <a @click="tweetExpanded = true">
+ <i class="fab fa-twitter"></i> {{ $ts.expandTweet }}
+ </a>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { url as local, lang } from '@/config';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ url: {
+ type: String,
+ require: true
+ },
+
+ detail: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ data() {
+ const self = this.url.startsWith(local);
+ return {
+ local,
+ fetching: true,
+ title: null,
+ description: null,
+ thumbnail: null,
+ icon: null,
+ sitename: null,
+ player: {
+ url: null,
+ width: null,
+ height: null
+ },
+ tweetId: null,
+ tweetExpanded: this.detail,
+ embedId: `embed${Math.random().toString().replace(/\D/,'')}`,
+ tweetHeight: 150,
+ tweetLeft: 0,
+ playerEnabled: false,
+ self: self,
+ attr: self ? 'to' : 'href',
+ target: self ? null : '_blank',
+ };
+ },
+
+ created() {
+ const requestUrl = new URL(this.url);
+
+ if (requestUrl.hostname == 'twitter.com') {
+ const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
+ if (m) this.tweetId = m[1];
+ }
+
+ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
+ requestUrl.hostname = 'www.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;
+ })
+ });
+
+ (window as any).addEventListener('message', this.adjustTweetHeight);
+ },
+
+ mounted() {
+ // 300pxないと絶対右にはみ出るので左に移動してしまう
+ const areaWidth = (this.$el as any)?.clientWidth;
+ if (areaWidth && areaWidth < 300) this.tweetLeft = areaWidth - 241;
+ },
+
+ methods: {
+ adjustTweetHeight(message: any) {
+ if (message.origin !== 'https://platform.twitter.com') return;
+ const embed = message.data?.['twttr.embed'];
+ if (embed?.method !== 'twttr.private.resize') return;
+ if (embed?.id !== this.embedId) return;
+ const height = embed?.params[0]?.height;
+ if (height) this.tweetHeight = height;
+ },
+ },
+
+ beforeUnmount() {
+ (window as any).removeEventListener('message', this.adjustTweetHeight);
+ },
+});
+</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 0 0 1px var(--divider);
+ border-radius: 8px;
+ 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/packages/client/src/components/user-info.vue b/packages/client/src/components/user-info.vue
new file mode 100644
index 0000000000..ce82443b84
--- /dev/null
+++ b/packages/client/src/components/user-info.vue
@@ -0,0 +1,142 @@
+<template>
+<div class="_panel vjnjpkug">
+ <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="title">
+ <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+ <p class="username"><MkAcct :user="user"/></p>
+ </div>
+ <div class="description">
+ <div class="mfm" v-if="user.description">
+ <Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+ </div>
+ <span v-else style="opacity: 0.7;">{{ $ts.noAccountDescription }}</span>
+ </div>
+ <div class="status">
+ <div>
+ <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
+ </div>
+ </div>
+ <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkFollowButton from './follow-button.vue';
+import { userPage } from '@/filters/user';
+
+export default defineComponent({
+ components: {
+ MkFollowButton
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ userPage,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vjnjpkug {
+ position: relative;
+
+ > .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 4px var(--panel);
+ }
+
+ > .title {
+ display: block;
+ padding: 10px 0 10px 88px;
+
+ > .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(--fg);
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ padding: 16px;
+ font-size: 0.8em;
+ border-top: solid 0.5px var(--divider);
+
+ > .mfm {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+ }
+
+ > .status {
+ padding: 10px 16px;
+ border-top: solid 0.5px var(--divider);
+
+ > div {
+ display: inline-block;
+ width: 33%;
+
+ > p {
+ margin: 0;
+ font-size: 0.7em;
+ color: var(--fg);
+ }
+
+ > span {
+ font-size: 1em;
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .koudoku-button {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-list.vue b/packages/client/src/components/user-list.vue
new file mode 100644
index 0000000000..733dbe0ad7
--- /dev/null
+++ b/packages/client/src/components/user-list.vue
@@ -0,0 +1,91 @@
+<template>
+<MkError v-if="error" @retry="init()"/>
+
+<div v-else class="efvhhmdq _isolated">
+ <div class="no-users" v-if="empty">
+ <p>{{ $ts.noUsers }}</p>
+ </div>
+ <div class="users">
+ <MkUserInfo class="user" v-for="user in users" :user="user" :key="user.id"/>
+ </div>
+ <button class="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :class="{ fetching: moreFetching }" v-show="more" :disabled="moreFetching">
+ <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }}
+ </button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import MkUserInfo from './user-info.vue';
+import { userPage } from '@/filters/user';
+
+export default defineComponent({
+ components: {
+ MkUserInfo,
+ },
+
+ 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;
+ }
+ },
+
+ methods: {
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.efvhhmdq {
+ > .no-users {
+ text-align: center;
+ }
+
+ > .users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
+ }
+
+ > .more {
+ display: block;
+ width: 100%;
+ padding: 16px;
+
+ &:hover {
+ background: rgba(#000, 0.025);
+ }
+
+ &:active {
+ background: rgba(#000, 0.05);
+ }
+
+ &.fetching {
+ cursor: wait;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-online-indicator.vue b/packages/client/src/components/user-online-indicator.vue
new file mode 100644
index 0000000000..afaf0e8736
--- /dev/null
+++ b/packages/client/src/components/user-online-indicator.vue
@@ -0,0 +1,50 @@
+<template>
+<div class="fzgwjkgc" :class="user.onlineStatus" v-tooltip="text"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ computed: {
+ text(): string {
+ switch (this.user.onlineStatus) {
+ case 'online': return this.$ts.online;
+ case 'active': return this.$ts.active;
+ case 'offline': return this.$ts.offline;
+ case 'unknown': return this.$ts.unknown;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fzgwjkgc {
+ box-shadow: 0 0 0 3px var(--panel);
+ border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる
+
+ &.online {
+ background: #58d4c9;
+ }
+
+ &.active {
+ background: #e4bc48;
+ }
+
+ &.offline {
+ background: #ea5353;
+ }
+
+ &.unknown {
+ background: #888;
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-preview.vue b/packages/client/src/components/user-preview.vue
new file mode 100644
index 0000000000..f7fd3f6b64
--- /dev/null
+++ b/packages/client/src/components/user-preview.vue
@@ -0,0 +1,192 @@
+<template>
+<transition name="popup" appear @after-leave="$emit('closed')">
+ <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }">
+ <div v-if="fetched" class="info">
+ <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="title">
+ <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+ <p class="username"><MkAcct :user="user"/></p>
+ </div>
+ <div class="description">
+ <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+ </div>
+ <div class="status">
+ <div>
+ <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
+ </div>
+ </div>
+ <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/>
+ </div>
+ <div v-else>
+ <MkLoading/>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import MkFollowButton from './follow-button.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkFollowButton
+ },
+
+ props: {
+ showing: {
+ type: Boolean,
+ required: true
+ },
+ q: {
+ type: String,
+ required: true
+ },
+ source: {
+ required: true
+ }
+ },
+
+ emits: ['closed', 'mouseover', 'mouseleave'],
+
+ data() {
+ return {
+ user: null,
+ fetched: false,
+ top: 0,
+ left: 0,
+ };
+ },
+
+ mounted() {
+ if (typeof this.q == 'object') {
+ this.user = this.q;
+ this.fetched = true;
+ } else {
+ const query = this.q.startsWith('@') ?
+ Acct.parse(this.q.substr(1)) :
+ { userId: this.q };
+
+ os.api('users/show', query).then(user => {
+ if (!this.showing) return;
+ this.user = user;
+ this.fetched = 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: {
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.popup-enter-active, .popup-leave-active {
+ transition: opacity 0.3s, transform 0.3s !important;
+}
+.popup-enter-from, .popup-leave-to {
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.fxxzrfni {
+ position: absolute;
+ z-index: 11000;
+ width: 300px;
+ overflow: hidden;
+ transform-origin: center top;
+
+ > .info {
+ > .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(--fg);
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ padding: 0 16px;
+ font-size: 0.8em;
+ color: var(--fg);
+ }
+
+ > .status {
+ padding: 8px 16px;
+
+ > div {
+ display: inline-block;
+ width: 33%;
+
+ > p {
+ margin: 0;
+ font-size: 0.7em;
+ color: var(--fg);
+ }
+
+ > span {
+ font-size: 1em;
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .koudoku-button {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/user-select-dialog.vue
new file mode 100644
index 0000000000..80f6293563
--- /dev/null
+++ b/packages/client/src/components/user-select-dialog.vue
@@ -0,0 +1,199 @@
+<template>
+<XModalWindow ref="dialog"
+ :with-ok-button="true"
+ :ok-button-disabled="selected == null"
+ @click="cancel()"
+ @close="cancel()"
+ @ok="ok()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.selectUser }}</template>
+ <div class="tbhwbxda _monolithic_">
+ <div class="_section">
+ <div class="_inputSplit">
+ <MkInput v-model="username" class="input" @update:modelValue="search" ref="username">
+ <template #label>{{ $ts.username }}</template>
+ <template #prefix>@</template>
+ </MkInput>
+ <MkInput v-model="host" class="input" @update:modelValue="search">
+ <template #label>{{ $ts.host }}</template>
+ <template #prefix>@</template>
+ </MkInput>
+ </div>
+ </div>
+ <div class="_section result" v-if="username != '' || host != ''" :class="{ hit: users.length > 0 }">
+ <div class="users" v-if="users.length > 0">
+ <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="user" class="name"/>
+ <MkAcct :user="user" class="acct"/>
+ </div>
+ </div>
+ </div>
+ <div v-else class="empty">
+ <span>{{ $ts.noUsers }}</span>
+ </div>
+ </div>
+ <div class="_section recent" v-if="username == '' && host == ''">
+ <div class="users">
+ <div class="user" v-for="user in recentUsers" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="user" class="name"/>
+ <MkAcct :user="user" class="acct"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkInput from './form/input.vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkInput,
+ XModalWindow,
+ },
+
+ props: {
+ },
+
+ emits: ['ok', 'cancel', 'closed'],
+
+ data() {
+ return {
+ username: '',
+ host: '',
+ recentUsers: [],
+ users: [],
+ selected: null,
+ };
+ },
+
+ async mounted() {
+ this.focus();
+
+ this.$nextTick(() => {
+ this.focus();
+ });
+
+ this.recentUsers = await os.api('users/show', {
+ userIds: this.$store.state.recentlyUsedUsers
+ });
+ },
+
+ methods: {
+ search() {
+ if (this.username == '' && this.host == '') {
+ this.users = [];
+ return;
+ }
+ os.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();
+ },
+
+ ok() {
+ this.$emit('ok', this.selected);
+ this.$refs.dialog.close();
+
+ // 最近使ったユーザー更新
+ let recents = this.$store.state.recentlyUsedUsers;
+ recents = recents.filter(x => x !== this.selected.id);
+ recents.unshift(this.selected.id);
+ this.$store.set('recentlyUsedUsers', recents.splice(0, 16));
+ },
+
+ cancel() {
+ this.$emit('cancel');
+ this.$refs.dialog.close();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tbhwbxda {
+ > ._section {
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ height: 100%;
+
+ &.result.hit {
+ padding: 0;
+ }
+
+ &.recent {
+ padding: 0;
+ }
+
+ > .users {
+ flex: 1;
+ overflow: auto;
+ padding: 8px 0;
+
+ > .user {
+ display: flex;
+ align-items: center;
+ padding: 8px var(--root-margin);
+ font-size: 14px;
+
+ &:hover {
+ background: var(--X7);
+ }
+
+ &.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;
+ }
+ }
+ }
+ }
+
+ > .empty {
+ opacity: 0.7;
+ text-align: center;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/users-dialog.vue b/packages/client/src/components/users-dialog.vue
new file mode 100644
index 0000000000..6eec5289b3
--- /dev/null
+++ b/packages/client/src/components/users-dialog.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="mk-users-dialog">
+ <div class="header">
+ <span>{{ title }}</span>
+ <button class="_button" @click="close()"><i class="fas fa-times"></i></button>
+ </div>
+
+ <div class="users">
+ <MkA v-for="item in items" class="user" :key="item.id" :to="userPage(extract ? extract(item) : item)">
+ <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="extract ? extract(item) : item" class="name"/>
+ <MkAcct :user="extract ? extract(item) : item" class="acct"/>
+ </div>
+ </MkA>
+ </div>
+ <button class="more _button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
+ </button>
+
+ <p class="empty" v-if="empty">{{ $ts.noUsers }}</p>
+
+ <MkError v-if="error" @retry="init()"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import { userPage } from '@/filters/user';
+
+export default defineComponent({
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ title: {
+ required: true
+ },
+ pagination: {
+ required: true
+ },
+ extract: {
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ userPage
+ }
+});
+</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/packages/client/src/components/visibility-picker.vue b/packages/client/src/components/visibility-picker.vue
new file mode 100644
index 0000000000..7a811b42f7
--- /dev/null
+++ b/packages/client/src/components/visibility-picker.vue
@@ -0,0 +1,167 @@
+<template>
+<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="gqyayizv _popup">
+ <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="1" key="public">
+ <div><i class="fas fa-globe"></i></div>
+ <div>
+ <span>{{ $ts._visibility.public }}</span>
+ <span>{{ $ts._visibility.publicDescription }}</span>
+ </div>
+ </button>
+ <button class="_button" @click="choose('home')" :class="{ active: v == 'home' }" data-index="2" key="home">
+ <div><i class="fas fa-home"></i></div>
+ <div>
+ <span>{{ $ts._visibility.home }}</span>
+ <span>{{ $ts._visibility.homeDescription }}</span>
+ </div>
+ </button>
+ <button class="_button" @click="choose('followers')" :class="{ active: v == 'followers' }" data-index="3" key="followers">
+ <div><i class="fas fa-unlock"></i></div>
+ <div>
+ <span>{{ $ts._visibility.followers }}</span>
+ <span>{{ $ts._visibility.followersDescription }}</span>
+ </div>
+ </button>
+ <button :disabled="localOnly" class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="4" key="specified">
+ <div><i class="fas fa-envelope"></i></div>
+ <div>
+ <span>{{ $ts._visibility.specified }}</span>
+ <span>{{ $ts._visibility.specifiedDescription }}</span>
+ </div>
+ </button>
+ <div class="divider"></div>
+ <button class="_button localOnly" @click="localOnly = !localOnly" :class="{ active: localOnly }" data-index="5" key="localOnly">
+ <div><i class="fas fa-biohazard"></i></div>
+ <div>
+ <span>{{ $ts._visibility.localOnly }}</span>
+ <span>{{ $ts._visibility.localOnlyDescription }}</span>
+ </div>
+ <div><i :class="localOnly ? 'fas fa-toggle-on' : 'fas fa-toggle-off'"></i></div>
+ </button>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+ props: {
+ currentVisibility: {
+ type: String,
+ required: true
+ },
+ currentLocalOnly: {
+ type: Boolean,
+ required: true
+ },
+ src: {
+ required: false
+ },
+ },
+ emits: ['change-visibility', 'change-local-only', 'closed'],
+ data() {
+ return {
+ v: this.currentVisibility,
+ localOnly: this.currentLocalOnly,
+ }
+ },
+ watch: {
+ localOnly() {
+ this.$emit('change-local-only', this.localOnly);
+ }
+ },
+ methods: {
+ choose(visibility) {
+ this.v = visibility;
+ this.$emit('change-visibility', visibility);
+ this.$nextTick(() => {
+ this.$refs.modal.close();
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.gqyayizv {
+ width: 240px;
+ padding: 8px 0;
+
+ > .divider {
+ margin: 8px 0;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > 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);
+ }
+
+ &.localOnly.active {
+ color: var(--accent);
+ background: inherit;
+ }
+
+ > *:nth-child(1) {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 10px;
+ width: 16px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+
+ > *:nth-child(2) {
+ flex: 1 1 auto;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > span:first-child {
+ display: block;
+ font-weight: bold;
+ }
+
+ > span:last-child:not(:first-child) {
+ opacity: 0.6;
+ }
+ }
+
+ > *:nth-child(3) {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-left: 10px;
+ width: 16px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/waiting-dialog.vue b/packages/client/src/components/waiting-dialog.vue
new file mode 100644
index 0000000000..35a760ea41
--- /dev/null
+++ b/packages/client/src/components/waiting-dialog.vue
@@ -0,0 +1,92 @@
+<template>
+<MkModal ref="modal" @click="success ? done() : () => {}" @closed="$emit('closed')">
+ <div class="iuyakobc" :class="{ iconOnly: (text == null) || success }">
+ <i v-if="success" class="fas fa-check icon success"></i>
+ <i v-else class="fas fa-spinner fa-pulse icon waiting"></i>
+ <div class="text" v-if="text && !success">{{ text }}<MkEllipsis/></div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ props: {
+ success: {
+ type: Boolean,
+ required: true,
+ },
+ showing: {
+ type: Boolean,
+ required: true,
+ },
+ text: {
+ type: String,
+ required: false,
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ };
+ },
+
+ watch: {
+ showing() {
+ if (!this.showing) this.done();
+ }
+ },
+
+ methods: {
+ done() {
+ this.$emit('done');
+ this.$refs.modal.close();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.iuyakobc {
+ position: relative;
+ padding: 32px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+ width: 250px;
+
+ &.iconOnly {
+ padding: 0;
+ width: 96px;
+ height: 96px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ > .icon {
+ font-size: 32px;
+
+ &.success {
+ color: var(--accent);
+ }
+
+ &.waiting {
+ opacity: 0.7;
+ }
+ }
+
+ > .text {
+ margin-top: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue
new file mode 100644
index 0000000000..8aec77796d
--- /dev/null
+++ b/packages/client/src/components/widgets.vue
@@ -0,0 +1,152 @@
+<template>
+<div class="vjoppmmu">
+ <template v-if="edit">
+ <header>
+ <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)">
+ <template #label>{{ $ts.selectWidget }}</template>
+ <option v-for="widget in widgetDefs" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option>
+ </MkSelect>
+ <MkButton inline @click="addWidget" primary><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+ <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton>
+ </header>
+ <XDraggable
+ v-model="_widgets"
+ item-key="id"
+ animation="150"
+ >
+ <template #item="{element}">
+ <div class="customize-container">
+ <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button>
+ <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button>
+ <component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" @updateProps="updateWidget(element.id, $event)"/>
+ </div>
+ </template>
+ </XDraggable>
+ </template>
+ <component v-else class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" @updateProps="updateWidget(widget.id, $event)"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import MkSelect from '@/components/form/select.vue';
+import MkButton from '@/components/ui/button.vue';
+import { widgets as widgetDefs } from '@/widgets';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ MkSelect,
+ MkButton,
+ },
+
+ props: {
+ widgets: {
+ type: Array,
+ required: true,
+ },
+ edit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'],
+
+ data() {
+ return {
+ widgetAdderSelected: null,
+ widgetDefs,
+ settings: {},
+ };
+ },
+
+ computed: {
+ _widgets: {
+ get() {
+ return this.widgets;
+ },
+ set(value) {
+ this.$emit('updateWidgets', value);
+ }
+ }
+ },
+
+ methods: {
+ configWidget(id) {
+ this.settings[id]();
+ },
+
+ addWidget() {
+ if (this.widgetAdderSelected == null) return;
+
+ this.$emit('addWidget', {
+ name: this.widgetAdderSelected,
+ id: uuid(),
+ data: {}
+ });
+
+ this.widgetAdderSelected = null;
+ },
+
+ removeWidget(widget) {
+ this.$emit('removeWidget', widget);
+ },
+
+ updateWidget(id, data) {
+ this.$emit('updateWidget', { id, data });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vjoppmmu {
+ > header {
+ margin: 16px 0;
+
+ > * {
+ width: 100%;
+ padding: 4px;
+ }
+ }
+
+ > .widget, .customize-container {
+ margin: var(--margin) 0;
+
+ &:first-of-type {
+ margin-top: 0;
+ }
+ }
+
+ .customize-container {
+ position: relative;
+ cursor: move;
+
+ > *:not(.remove):not(.config) {
+ pointer-events: none;
+ }
+
+ > .config,
+ > .remove {
+ position: absolute;
+ z-index: 10000;
+ top: 8px;
+ width: 32px;
+ height: 32px;
+ color: #fff;
+ background: rgba(#000, 0.7);
+ border-radius: 4px;
+ }
+
+ > .config {
+ right: 8px + 8px + 32px;
+ }
+
+ > .remove {
+ right: 8px;
+ }
+ }
+}
+</style>