summaryrefslogtreecommitdiff
path: root/src/client/pages
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/pages')
-rw-r--r--src/client/pages/about.vue106
-rw-r--r--src/client/pages/announcements.vue73
-rw-r--r--src/client/pages/auth.form.vue63
-rw-r--r--src/client/pages/auth.vue93
-rw-r--r--src/client/pages/drive.vue87
-rw-r--r--src/client/pages/explore.vue212
-rw-r--r--src/client/pages/favorites.vue48
-rw-r--r--src/client/pages/featured.vue47
-rw-r--r--src/client/pages/follow-requests.vue142
-rw-r--r--src/client/pages/follow.vue98
-rw-r--r--src/client/pages/index.home.vue190
-rw-r--r--src/client/pages/index.vue15
-rw-r--r--src/client/pages/index.welcome.entrance.vue103
-rw-r--r--src/client/pages/index.welcome.setup.vue102
-rw-r--r--src/client/pages/index.welcome.vue34
-rw-r--r--src/client/pages/instance/announcements.vue129
-rw-r--r--src/client/pages/instance/emojis.vue253
-rw-r--r--src/client/pages/instance/federation.instance.vue576
-rw-r--r--src/client/pages/instance/federation.vue165
-rw-r--r--src/client/pages/instance/files.vue54
-rw-r--r--src/client/pages/instance/index.vue393
-rw-r--r--src/client/pages/instance/monitor.vue381
-rw-r--r--src/client/pages/instance/queue.queue.vue204
-rw-r--r--src/client/pages/instance/queue.vue79
-rw-r--r--src/client/pages/instance/stats.vue491
-rw-r--r--src/client/pages/instance/users.vue203
-rw-r--r--src/client/pages/mentions.vue46
-rw-r--r--src/client/pages/messages.vue49
-rw-r--r--src/client/pages/messaging-room.form.vue357
-rw-r--r--src/client/pages/messaging-room.message.vue336
-rw-r--r--src/client/pages/messaging-room.vue395
-rw-r--r--src/client/pages/messaging.vue328
-rw-r--r--src/client/pages/my-antennas/index.antenna.vue174
-rw-r--r--src/client/pages/my-antennas/index.vue80
-rw-r--r--src/client/pages/my-lists/index.vue75
-rw-r--r--src/client/pages/my-lists/list.vue163
-rw-r--r--src/client/pages/note.vue55
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.button.vue83
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.counter.vue43
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.if.vue91
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.image.vue78
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.number-input.vue43
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.post.vue41
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.radio-button.vue50
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.section.vue104
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.switch.vue50
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.text-input.vue43
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.text.vue60
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.textarea-input.vue44
-rw-r--r--src/client/pages/page-editor/els/page-editor.el.textarea.vue60
-rw-r--r--src/client/pages/page-editor/page-editor.blocks.vue66
-rw-r--r--src/client/pages/page-editor/page-editor.container.vue152
-rw-r--r--src/client/pages/page-editor/page-editor.script-block.vue278
-rw-r--r--src/client/pages/page-editor/page-editor.vue516
-rw-r--r--src/client/pages/page.vue69
-rw-r--r--src/client/pages/pages.vue78
-rw-r--r--src/client/pages/search.vue55
-rw-r--r--src/client/pages/settings/2fa.vue264
-rw-r--r--src/client/pages/settings/drive.vue212
-rw-r--r--src/client/pages/settings/general.vue108
-rw-r--r--src/client/pages/settings/import-export.vue121
-rw-r--r--src/client/pages/settings/index.vue94
-rw-r--r--src/client/pages/settings/integration.vue122
-rw-r--r--src/client/pages/settings/mute-block.vue76
-rw-r--r--src/client/pages/settings/privacy.vue69
-rw-r--r--src/client/pages/settings/profile.vue246
-rw-r--r--src/client/pages/settings/reaction.vue62
-rw-r--r--src/client/pages/settings/security.vue87
-rw-r--r--src/client/pages/settings/theme.vue76
-rw-r--r--src/client/pages/tag.vue49
-rw-r--r--src/client/pages/user/follow-list.vue140
-rw-r--r--src/client/pages/user/index.activity.vue114
-rw-r--r--src/client/pages/user/index.photos.vue98
-rw-r--r--src/client/pages/user/index.timeline.vue79
-rw-r--r--src/client/pages/user/index.vue476
75 files changed, 10896 insertions, 0 deletions
diff --git a/src/client/pages/about.vue b/src/client/pages/about.vue
new file mode 100644
index 0000000000..e47856bb94
--- /dev/null
+++ b/src/client/pages/about.vue
@@ -0,0 +1,106 @@
+<template>
+<div class="mmnnbwxb">
+ <portal to="icon"><fa :icon="faInfoCircle"/></portal>
+ <portal to="title">{{ $t('about') }}</portal>
+
+ <section class="_section info" v-if="meta">
+ <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div>
+ <div class="_content" v-if="meta.description">
+ <div>{{ meta.description }}</div>
+ </div>
+ <div class="_content table">
+ <div><b>{{ $t('administrator') }}</b><span>{{ meta.maintainerName }}</span></div>
+ <div><b></b><span>{{ meta.maintainerEmail }}</span></div>
+ </div>
+ <div class="_content table" v-if="stats">
+ <div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div>
+ <div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div>
+ </div>
+ <div class="_content table">
+ <div><b>Misskey</b><span>v{{ version }}</span></div>
+ </div>
+ </section>
+
+ <section class="_section aboutMisskey">
+ <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('aboutMisskey') }}</div>
+ <div class="_content">
+ <div style="margin-bottom: 1em;">{{ $t('aboutMisskeyText') }}</div>
+ <div>{{ $t('misskeyMembers') }}</div>
+ <span class="members">
+ <a href="https://github.com/syuilo" target="_blank">@syuilo</a>
+ <a href="https://github.com/AyaMorisawa" target="_blank">@AyaMorisawa</a>
+ <a href="https://github.com/mei23" target="_blank">@mei23</a>
+ <a href="https://github.com/acid-chicken" target="_blank">@acid-chicken</a>
+ <a href="https://github.com/tamaina" target="_blank">@tamaina</a>
+ <a href="https://github.com/rinsuki" target="_blank">@rinsuki</a>
+ </span>
+ <div style="margin-top: 1em;">{{ $t('misskeySource') }}</div>
+ <a href="https://github.com/syuilo/misskey" target="_blank" style="color: var(--link);">https://github.com/syuilo/misskey</a>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
+import { version } from '../config';
+import i18n from '../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('instance') as string
+ };
+ },
+
+ data() {
+ return {
+ version,
+ meta: null,
+ stats: null,
+ serverInfo: null,
+ faInfoCircle
+ }
+ },
+
+ created() {
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ });
+
+ this.$root.api('stats').then(res => {
+ this.stats = res;
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mmnnbwxb {
+ > .info {
+ > .table {
+ > div {
+ display: flex;
+
+ > * {
+ flex: 1;
+ }
+ }
+ }
+ }
+
+ > .aboutMisskey {
+ > ._content {
+ > .members {
+ > a {
+ color: var(--link);
+ margin-right: 0.5em;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/announcements.vue b/src/client/pages/announcements.vue
new file mode 100644
index 0000000000..586bc0c03c
--- /dev/null
+++ b/src/client/pages/announcements.vue
@@ -0,0 +1,73 @@
+<template>
+<div>
+ <portal to="icon"><fa :icon="faBroadcastTower"/></portal>
+ <portal to="title">{{ $t('announcements') }}</portal>
+
+ <mk-pagination :pagination="pagination" #default="{items}" class="ruryvtyk" ref="list">
+ <section class="_section announcement" v-for="(announcement, i) in items" :key="announcement.id" :data-index="i">
+ <div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
+ <div class="_content">
+ <mfm :text="announcement.text"/>
+ <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt=""/>
+ </div>
+ <div class="_footer" v-if="$store.getters.isSignedIn && !announcement.isRead">
+ <mk-button @click="read(announcement)" primary><fa :icon="faCheck"/> {{ $t('gotIt') }}</mk-button>
+ </div>
+ </section>
+ </mk-pagination>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCheck, faBroadcastTower } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../i18n';
+import MkPagination from '../components/ui/pagination.vue';
+import MkButton from '../components/ui/button.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('announcements') as string
+ };
+ },
+
+ components: {
+ MkPagination,
+ MkButton
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'announcements',
+ limit: 10,
+ },
+ faCheck, faBroadcastTower
+ };
+ },
+
+ methods: {
+ read(announcement) {
+ announcement.isRead = true;
+ this.$root.api('i/read-announcement', { announcementId: announcement.id });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ruryvtyk {
+ > .announcement {
+ > ._content {
+ > img {
+ display: block;
+ max-height: 300px;
+ max-width: 100%;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/auth.form.vue b/src/client/pages/auth.form.vue
new file mode 100644
index 0000000000..80a792eb36
--- /dev/null
+++ b/src/client/pages/auth.form.vue
@@ -0,0 +1,63 @@
+<template>
+<section class="_section">
+ <div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div>
+ <div class="_content">
+ <h2>{{ app.name }}</h2>
+ <p class="id">{{ app.id }}</p>
+ <p class="description">{{ app.description }}</p>
+ </div>
+ <div class="_content">
+ <h2>{{ $t('_auth.permissionAsk') }}</h2>
+ <ul>
+ <template v-for="p in app.permission">
+ <li :key="p">{{ $t(`_permissions.${p}`) }}</li>
+ </template>
+ </ul>
+ </div>
+ <div class="_footer">
+ <mk-button @click="cancel" inline>{{ $t('cancel') }}</mk-button>
+ <mk-button @click="accept" inline primary>{{ $t('accept') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../i18n';
+import MkButton from '../components/ui/button.vue';
+
+export default Vue.extend({
+ i18n,
+ components: {
+ MkButton
+ },
+ props: ['session'],
+ computed: {
+ name(): string {
+ const el = document.createElement('div');
+ el.textContent = this.app.name
+ return el.innerHTML;
+ },
+ app(): any {
+ return this.session.app;
+ }
+ },
+ methods: {
+ cancel() {
+ this.$root.api('auth/deny', {
+ token: this.session.token
+ }).then(() => {
+ this.$emit('denied');
+ });
+ },
+
+ accept() {
+ this.$root.api('auth/accept', {
+ token: this.session.token
+ }).then(() => {
+ this.$emit('accepted');
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/auth.vue b/src/client/pages/auth.vue
new file mode 100644
index 0000000000..15ec81e019
--- /dev/null
+++ b/src/client/pages/auth.vue
@@ -0,0 +1,93 @@
+<template>
+<div class="_panel" v-if="$store.getters.isSignedIn && fetching">
+ <mk-loading/>
+</div>
+<div v-else-if="$store.getters.isSignedIn">
+ <x-form
+ class="form"
+ ref="form"
+ v-if="state == 'waiting'"
+ :session="session"
+ @denied="state = 'denied'"
+ @accepted="accepted"
+ />
+ <div class="denied _panel" v-if="state == 'denied'">
+ <h1>{{ $t('denied') }}</h1>
+ <p>{{ $t('denied-paragraph') }}</p>
+ </div>
+ <div class="accepted _panel" v-if="state == 'accepted'">
+ <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$t('allowed') }}</h1>
+ <p v-if="session.app.callbackUrl">{{ $t('callback-url') }}<mk-ellipsis/></p>
+ <p v-if="!session.app.callbackUrl">{{ $t('please-go-back') }}</p>
+ </div>
+ <div class="error _panel" v-if="state == 'fetch-session-error'">
+ <p>{{ $t('error') }}</p>
+ </div>
+</div>
+<div class="signin" v-else>
+ <h1>{{ $t('sign-in') }}</h1>
+ <mk-signin/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../i18n';
+import XForm from './auth.form.vue';
+
+export default Vue.extend({
+ i18n,
+ components: {
+ XForm
+ },
+ data() {
+ return {
+ state: null,
+ session: null,
+ fetching: true
+ };
+ },
+ computed: {
+ token(): string {
+ return this.$route.params.token;
+ }
+ },
+ mounted() {
+ if (!this.$store.getters.isSignedIn) return;
+
+ // Fetch session
+ this.$root.api('auth/session/show', {
+ token: this.token
+ }).then(session => {
+ this.session = session;
+ this.fetching = false;
+
+ // 既に連携していた場合
+ if (this.session.app.isAuthorized) {
+ this.$root.api('auth/accept', {
+ token: this.session.token
+ }).then(() => {
+ this.accepted();
+ });
+ } else {
+ this.state = 'waiting';
+ }
+ }).catch(error => {
+ this.state = 'fetch-session-error';
+ this.fetching = false;
+ });
+ },
+ methods: {
+ accepted() {
+ this.state = 'accepted';
+ if (this.session.app.callbackUrl) {
+ location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/src/client/pages/drive.vue b/src/client/pages/drive.vue
new file mode 100644
index 0000000000..24a0d91ff6
--- /dev/null
+++ b/src/client/pages/drive.vue
@@ -0,0 +1,87 @@
+<template>
+<div>
+ <portal to="header">
+ <button @click="menu" class="_button _jmoebdiw_">
+ <fa :icon="faCloud" style="margin-right: 8px;"/>
+ <span v-if="folder">{{ $t('drive') }} ({{ folder.name }})</span>
+ <span v-else>{{ $t('drive') }}</span>
+ <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
+ </button>
+ </portal>
+ <x-drive ref="drive" @cd="x => folder = x"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCloud, faAngleDown, faAngleUp, faFolderPlus, faUpload, faLink, faICursor, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
+import XDrive from '../components/drive.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('drive') as string
+ };
+ },
+
+ components: {
+ XDrive
+ },
+
+ data() {
+ return {
+ menuOpened: false,
+ folder: null,
+ faCloud, faAngleDown, faAngleUp
+ };
+ },
+
+ methods: {
+ menu(ev) {
+ this.menuOpened = true;
+ this.$root.menu({
+ items: [{
+ text: this.$t('addFile'),
+ type: 'label'
+ }, {
+ text: this.$t('upload'),
+ icon: faUpload,
+ action: () => { this.$refs.drive.selectLocalFile(); }
+ }, {
+ text: this.$t('fromUrl'),
+ icon: faLink,
+ action: () => { this.$refs.drive.urlUpload(); }
+ }, null, {
+ text: this.folder ? this.folder.name : this.$t('drive'),
+ type: 'label'
+ }, this.folder ? {
+ text: this.$t('renameFolder'),
+ icon: faICursor,
+ action: () => { this.$refs.drive.renameFolder(); }
+ } : undefined, this.folder ? {
+ text: this.$t('deleteFolder'),
+ icon: faTrashAlt,
+ action: () => { this.$refs.drive.deleteFolder(); }
+ } : undefined, {
+ text: this.$t('createFolder'),
+ icon: faFolderPlus,
+ action: () => { this.$refs.drive.createFolder(); }
+ }],
+ fixed: true,
+ noCenter: true,
+ source: ev.currentTarget || ev.target
+ }).then(() => {
+ this.menuOpened = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss">
+._jmoebdiw_ {
+ height: 100%;
+ padding: 0 16px;
+ font-weight: bold;
+}
+</style>
diff --git a/src/client/pages/explore.vue b/src/client/pages/explore.vue
new file mode 100644
index 0000000000..ba2c3faa6c
--- /dev/null
+++ b/src/client/pages/explore.vue
@@ -0,0 +1,212 @@
+<template>
+<div>
+ <portal to="icon"><fa :icon="faHashtag"/></portal>
+ <portal to="title">{{ $t('explore') }}</portal>
+
+ <div class="localfedi7 _panel" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }">
+ <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header>
+ <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div>
+ </div>
+
+ <template v-if="tag == null">
+ <x-user-list :pagination="pinnedUsers" :expanded="false">
+ <fa :icon="faBookmark" fixed-width/>{{ $t('pinnedUsers') }}
+ </x-user-list>
+ <x-user-list :pagination="popularUsers" :expanded="false">
+ <fa :icon="faChartLine" fixed-width/>{{ $t('popularUsers') }}
+ </x-user-list>
+ <x-user-list :pagination="recentlyUpdatedUsers" :expanded="false">
+ <fa :icon="faCommentAlt" fixed-width/>{{ $t('recentlyUpdatedUsers') }}
+ </x-user-list>
+ <x-user-list :pagination="recentlyRegisteredUsers" :expanded="false">
+ <fa :icon="faPlus" fixed-width/>{{ $t('recentlyRegisteredUsers') }}
+ </x-user-list>
+ </template>
+
+ <div class="localfedi7 _panel" v-if="tag == null" :style="{ backgroundImage: `url(/assets/fedi.jpg)`, marginTop: 'var(--margin)' }">
+ <header><span>{{ $t('exploreFediverse') }}</span></header>
+ </div>
+
+ <mk-container :body-togglable="true" :expanded="false" ref="tags">
+ <template #header><fa :icon="faHashtag" fixed-width/>{{ $t('popularTags') }}</template>
+
+ <div class="vxjfqztj">
+ <router-link v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</router-link>
+ <router-link v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</router-link>
+ </div>
+ </mk-container>
+
+ <x-user-list v-if="tag != null" :pagination="tagUsers" :key="`${tag}`">
+ <fa :icon="faHashtag" fixed-width/>{{ tag }}
+ </x-user-list>
+ <template v-if="tag == null">
+ <x-user-list :pagination="popularUsersF" :expanded="false">
+ <fa :icon="faChartLine" fixed-width/>{{ $t('popularUsers') }}
+ </x-user-list>
+ <x-user-list :pagination="recentlyUpdatedUsersF" :expanded="false">
+ <fa :icon="faCommentAlt" fixed-width/>{{ $t('recentlyUpdatedUsers') }}
+ </x-user-list>
+ <x-user-list :pagination="recentlyRegisteredUsersF" :expanded="false">
+ <fa :icon="faRocket" fixed-width/>{{ $t('recentlyDiscoveredUsers') }}
+ </x-user-list>
+ </template>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faChartLine, faPlus, faHashtag, faRocket } from '@fortawesome/free-solid-svg-icons';
+import { faBookmark, faCommentAlt } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../i18n';
+import XUserList from '../components/user-list.vue';
+import MkContainer from '../components/ui/container.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('explore') as string
+ };
+ },
+
+ components: {
+ XUserList,
+ MkContainer,
+ },
+
+ props: {
+ tag: {
+ type: String,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ pinnedUsers: { endpoint: 'pinned-users' },
+ popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'local',
+ sort: '+follower',
+ } },
+ recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ sort: '+updatedAt',
+ } },
+ recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ state: 'alive',
+ sort: '+createdAt',
+ } },
+ popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'remote',
+ sort: '+follower',
+ } },
+ recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+updatedAt',
+ } },
+ recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+createdAt',
+ } },
+ tagsLocal: [],
+ tagsRemote: [],
+ stats: null,
+ meta: null,
+ num: Vue.filter('number'),
+ faBookmark, faChartLine, faCommentAlt, faPlus, faHashtag, faRocket
+ };
+ },
+
+ computed: {
+ tagUsers(): any {
+ return {
+ endpoint: 'hashtags/users',
+ limit: 30,
+ params: {
+ tag: this.tag,
+ origin: 'combined',
+ sort: '+follower',
+ }
+ };
+ },
+ },
+
+ watch: {
+ tag() {
+ if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
+ }
+ },
+
+ created() {
+ this.$root.api('hashtags/list', {
+ sort: '+attachedLocalUsers',
+ attachedToLocalUserOnly: true,
+ limit: 30
+ }).then(tags => {
+ this.tagsLocal = tags;
+ });
+ this.$root.api('hashtags/list', {
+ sort: '+attachedRemoteUsers',
+ attachedToRemoteUserOnly: true,
+ limit: 30
+ }).then(tags => {
+ this.tagsRemote = tags;
+ });
+ this.$root.api('stats').then(stats => {
+ this.stats = stats;
+ });
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.localfedi7 {
+ color: #fff;
+ padding: 16px;
+ height: 80px;
+ background-position: 50%;
+ background-size: cover;
+ margin-bottom: var(--margin);
+
+ > * {
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > span {
+ display: inline-block;
+ padding: 6px 8px;
+ background: rgba(0, 0, 0, 0.7);
+ }
+ }
+
+ > header {
+ font-size: 20px;
+ font-weight: bold;
+ }
+
+ > div {
+ font-size: 14px;
+ opacity: 0.8;
+ }
+}
+
+.vxjfqztj {
+ padding: 16px;
+
+ > * {
+ margin-right: 16px;
+
+ &.local {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/favorites.vue b/src/client/pages/favorites.vue
new file mode 100644
index 0000000000..59bef2ca91
--- /dev/null
+++ b/src/client/pages/favorites.vue
@@ -0,0 +1,48 @@
+<template>
+<div>
+ <portal to="icon"><fa :icon="faStar"/></portal>
+ <portal to="title">{{ $t('favorites') }}</portal>
+ <x-notes :pagination="pagination" :detail="true" :extract="items => items.map(item => item.note)" @before="before()" @after="after()"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faStar } from '@fortawesome/free-solid-svg-icons';
+import Progress from '../scripts/loading';
+import XNotes from '../components/notes.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('favorites') as string
+ };
+ },
+
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'i/favorites',
+ limit: 10,
+ params: () => ({
+ })
+ },
+ faStar
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/featured.vue b/src/client/pages/featured.vue
new file mode 100644
index 0000000000..e6293e9e83
--- /dev/null
+++ b/src/client/pages/featured.vue
@@ -0,0 +1,47 @@
+<template>
+<div>
+ <portal to="icon"><fa :icon="faFireAlt"/></portal>
+ <portal to="title">{{ $t('featured') }}</portal>
+ <x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faFireAlt } from '@fortawesome/free-solid-svg-icons';
+import Progress from '../scripts/loading';
+import XNotes from '../components/notes.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('featured') as string
+ };
+ },
+
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'notes/featured',
+ limit: 10,
+ offsetMode: true
+ },
+ faFireAlt
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/follow-requests.vue b/src/client/pages/follow-requests.vue
new file mode 100644
index 0000000000..c302088b97
--- /dev/null
+++ b/src/client/pages/follow-requests.vue
@@ -0,0 +1,142 @@
+<template>
+<mk-pagination :pagination="pagination" #default="{items}" class="mk-follow-requests" ref="list">
+ <div class="user _panel" v-for="(req, i) in items" :key="req.id" :data-index="i">
+ <mk-avatar class="avatar" :user="req.follower"/>
+ <div class="body">
+ <div class="name">
+ <router-link class="name" :to="req.follower | userPage" v-user-preview="req.follower.id"><mk-user-name :user="req.follower"/></router-link>
+ <p class="acct">@{{ req.follower | acct }}</p>
+ </div>
+ <div class="description" v-if="req.follower.description" :title="req.follower.description">
+ <mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$store.state.i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
+ </div>
+ <div class="actions">
+ <button class="_button" @click="accept(req.follower)"><fa :icon="faCheck"/></button>
+ <button class="_button" @click="reject(req.follower)"><fa :icon="faTimes"/></button>
+ </div>
+ </div>
+ </div>
+</mk-pagination>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons';
+import MkPagination from '../components/ui/pagination.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('followRequests') as string
+ };
+ },
+
+ components: {
+ MkPagination
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'following/requests/list',
+ limit: 10,
+ },
+ faCheck, faTimes
+ };
+ },
+
+ methods: {
+ accept(user) {
+ this.$root.api('following/requests/accept', { userId: user.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ },
+ reject(user) {
+ this.$root.api('following/requests/reject', { userId: user.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-follow-requests {
+ > .user {
+ display: flex;
+ padding: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 42px;
+ height: 42px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ display: flex;
+ width: calc(100% - 54px);
+ position: relative;
+
+ > .name {
+ width: 45%;
+
+ @media (max-width: 500px) {
+ width: 100%;
+ }
+
+ > .name,
+ > .acct {
+ display: block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ margin: 0;
+ }
+
+ > .name {
+ font-size: 16px;
+ line-height: 24px;
+ }
+
+ > .acct {
+ font-size: 15px;
+ line-height: 16px;
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ width: 55%;
+ line-height: 42px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 0.7;
+ font-size: 14px;
+ padding-right: 40px;
+ padding-left: 8px;
+ box-sizing: border-box;
+
+ @media (max-width: 500px) {
+ display: none;
+ }
+ }
+
+ > .actions {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ margin: auto 0;
+
+ > button {
+ padding: 12px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/follow.vue b/src/client/pages/follow.vue
new file mode 100644
index 0000000000..d765259737
--- /dev/null
+++ b/src/client/pages/follow.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="mk-follow-page">
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ created() {
+ const acct = new URL(location.href).searchParams.get('acct');
+ if (acct == null) return;
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ text: this.$t('fetchingAsApObject') + '...',
+ showOkButton: false,
+ showCancelButton: false,
+ cancelableByBgClick: false
+ });
+
+ if (acct.startsWith('https://')) {
+ this.$root.api('ap/show', {
+ uri: acct
+ }).then(res => {
+ if (res.type == 'User') {
+ this.follow(res.object);
+ } else {
+ this.$root.dialog({
+ type: 'error',
+ text: 'Not a user'
+ }).then(() => {
+ window.close();
+ });
+ }
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ }).then(() => {
+ window.close();
+ });
+ }).finally(() => {
+ dialog.close();
+ });
+ } else {
+ this.$root.api('users/show', parseAcct(acct)).then(user => {
+ this.follow(user);
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ }).then(() => {
+ window.close();
+ });
+ }).finally(() => {
+ dialog.close();
+ });
+ }
+ },
+
+ methods: {
+ async follow(user) {
+ const { canceled } = await this.$root.dialog({
+ type: 'question',
+ text: this.$t('followConfirm', { name: user.name || user.username }),
+ showCancelButton: true
+ });
+
+ if (canceled) {
+ window.close();
+ return;
+ }
+
+ this.$root.api('following/create', {
+ userId: user.id
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ }).then(() => {
+ window.close();
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ }).then(() => {
+ window.close();
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/index.home.vue b/src/client/pages/index.home.vue
new file mode 100644
index 0000000000..0c3d2e7f86
--- /dev/null
+++ b/src/client/pages/index.home.vue
@@ -0,0 +1,190 @@
+<template>
+<div class="mk-home" v-hotkey.global="keymap">
+ <portal to="header">
+ <button @click="choose" class="_button _kjvfvyph_">
+ <i><fa v-if="$store.state.i.hasUnreadAntenna" :icon="faCircle"/></i>
+ <fa v-if="src === 'home'" :icon="faHome"/>
+ <fa v-if="src === 'local'" :icon="faComments"/>
+ <fa v-if="src === 'social'" :icon="faShareAlt"/>
+ <fa v-if="src === 'global'" :icon="faGlobe"/>
+ <fa v-if="src === 'list'" :icon="faListUl"/>
+ <fa v-if="src === 'antenna'" :icon="faSatellite"/>
+ <span style="margin-left: 8px;">{{ src === 'list' ? list.name : src === 'antenna' ? antenna.name : $t('_timelines.' + src) }}</span>
+ <fa :icon="menuOpened ? faAngleUp : faAngleDown" style="margin-left: 8px;"/>
+ </button>
+ </portal>
+ <x-timeline ref="tl" :key="src === 'list' ? `list:${list.id}` : src === 'antenna' ? `antenna:${antenna.id}` : src" :src="src" :list="list" :antenna="antenna" @before="before()" @after="after()"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faListUl, faSatellite, faCircle } from '@fortawesome/free-solid-svg-icons';
+import { faComments } from '@fortawesome/free-regular-svg-icons';
+import Progress from '../scripts/loading';
+import XTimeline from '../components/timeline.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('timeline') as string
+ };
+ },
+
+ components: {
+ XTimeline
+ },
+
+ data() {
+ return {
+ src: 'home',
+ list: null,
+ antenna: null,
+ menuOpened: false,
+ faAngleDown, faAngleUp, faHome, faShareAlt, faGlobe, faComments, faListUl, faSatellite, faCircle
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 't': this.focus
+ };
+ }
+ },
+
+ watch: {
+ src() {
+ this.showNav = false;
+ this.saveSrc();
+ },
+ list(x) {
+ this.showNav = false;
+ this.saveSrc();
+ if (x != null) this.antenna = null;
+ },
+ antenna(x) {
+ this.showNav = false;
+ this.saveSrc();
+ if (x != null) this.list = null;
+ },
+ },
+
+ created() {
+ this.$root.getMeta().then((meta: Record<string, any>) => {
+ if (!(
+ this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin
+ ) && this.src === 'global') this.src = 'local';
+ if (!(
+ this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin
+ ) && ['local', 'social'].includes(this.src)) this.src = 'home';
+ });
+ if (this.$store.state.device.tl) {
+ this.src = this.$store.state.device.tl.src;
+ if (this.src === 'list') {
+ this.list = this.$store.state.device.tl.arg;
+ } else if (this.src === 'antenna') {
+ this.antenna = this.$store.state.device.tl.arg;
+ }
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ async choose(ev) {
+ this.menuOpened = true;
+ const [antennas, lists] = await Promise.all([
+ this.$root.api('antennas/list'),
+ this.$root.api('users/lists/list')
+ ]);
+ const antennaItems = antennas.map(antenna => ({
+ text: antenna.name,
+ icon: faSatellite,
+ indicate: antenna.hasUnreadNote,
+ action: () => {
+ this.antenna = antenna;
+ this.setSrc('antenna');
+ }
+ }));
+ const listItems = lists.map(list => ({
+ text: list.name,
+ icon: faListUl,
+ action: () => {
+ this.list = list;
+ this.setSrc('list');
+ }
+ }));
+ this.$root.menu({
+ items: [{
+ text: this.$t('_timelines.home'),
+ icon: faHome,
+ action: () => { this.setSrc('home') }
+ }, {
+ text: this.$t('_timelines.local'),
+ icon: faComments,
+ action: () => { this.setSrc('local') }
+ }, {
+ text: this.$t('_timelines.social'),
+ icon: faShareAlt,
+ action: () => { this.setSrc('social') }
+ }, {
+ text: this.$t('_timelines.global'),
+ icon: faGlobe,
+ action: () => { this.setSrc('global') }
+ }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems],
+ fixed: true,
+ noCenter: true,
+ source: ev.currentTarget || ev.target
+ }).then(() => {
+ this.menuOpened = false;
+ });
+ },
+
+ setSrc(src) {
+ this.src = src;
+ },
+
+ saveSrc() {
+ this.$store.commit('device/setTl', {
+ src: this.src,
+ arg: this.src == 'list' ? this.list : this.antenna
+ });
+ },
+
+ focus() {
+ (this.$refs.tl as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss">
+@keyframes blink {
+ 0% { opacity: 1; }
+ 30% { opacity: 1; }
+ 90% { opacity: 0; }
+}
+
+._kjvfvyph_ {
+ position: relative;
+ height: 100%;
+ padding: 0 16px;
+ font-weight: bold;
+
+ > i {
+ position: absolute;
+ top: 16px;
+ right: 8px;
+ color: var(--accent);
+ font-size: 12px;
+ animation: blink 1s infinite;
+ }
+}
+</style>
diff --git a/src/client/pages/index.vue b/src/client/pages/index.vue
new file mode 100644
index 0000000000..732d9b71cc
--- /dev/null
+++ b/src/client/pages/index.vue
@@ -0,0 +1,15 @@
+<template>
+<component :is="$store.getters.isSignedIn ? 'home' : 'welcome'"></component>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Home from './index.home.vue';
+
+export default Vue.extend({
+ components: {
+ Home,
+ Welcome: () => import('./index.welcome.vue').then(m => m.default),
+ }
+});
+</script>
diff --git a/src/client/pages/index.welcome.entrance.vue b/src/client/pages/index.welcome.entrance.vue
new file mode 100644
index 0000000000..1b0cc7d034
--- /dev/null
+++ b/src/client/pages/index.welcome.entrance.vue
@@ -0,0 +1,103 @@
+<template>
+<div class="rsqzvsbo">
+ <div class="_panel about">
+ <div class="banner" :style="{ backgroundImage: `url(${ banner })` }"></div>
+ <div class="body">
+ <h1 class="name" v-html="name || host"></h1>
+ <div class="desc" v-html="description || $t('introMisskey')"></div>
+ <mk-button @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</mk-button>
+ <mk-button @click="signin()" style="display: inline-block;">{{ $t('login') }}</mk-button>
+ </div>
+ </div>
+ <x-notes :pagination="featuredPagination"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { toUnicode } from 'punycode';
+import XSigninDialog from '../components/signin-dialog.vue';
+import XSignupDialog from '../components/signup-dialog.vue';
+import MkButton from '../components/ui/button.vue';
+import XNotes from '../components/notes.vue';
+import i18n from '../i18n';
+import { host } from '../config';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ XNotes,
+ },
+
+ data() {
+ return {
+ featuredPagination: {
+ endpoint: 'notes/featured',
+ limit: 10,
+ noPaging: true,
+ },
+ host: toUnicode(host),
+ meta: null,
+ name: null,
+ description: null,
+ banner: null,
+ announcements: [],
+ };
+ },
+
+ created() {
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ this.name = meta.name;
+ this.description = meta.description;
+ this.announcements = meta.announcements;
+ this.banner = meta.bannerUrl;
+ });
+
+ this.$root.api('stats').then(stats => {
+ this.stats = stats;
+ });
+ },
+
+ methods: {
+ signin() {
+ this.$root.new(XSigninDialog, {
+ autoSet: true
+ });
+ },
+
+ signup() {
+ this.$root.new(XSignupDialog);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+ > .about {
+ overflow: hidden;
+ margin-bottom: var(--margin);
+
+ > .banner {
+ height: 170px;
+ background-size: cover;
+ background-position: center center;
+ }
+
+ > .body {
+ padding: 32px;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ }
+
+ > .name {
+ margin: 0 0 0.5em 0;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/index.welcome.setup.vue b/src/client/pages/index.welcome.setup.vue
new file mode 100644
index 0000000000..a339ac0a28
--- /dev/null
+++ b/src/client/pages/index.welcome.setup.vue
@@ -0,0 +1,102 @@
+<template>
+<form class="mk-setup" @submit.prevent="submit()">
+ <h1>Welcome to Misskey!</h1>
+ <div>
+ <p>{{ $t('intro') }}</p>
+ <mk-input v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required>
+ <span>{{ $t('username') }}</span>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ </mk-input>
+ <mk-input v-model="password" type="password">
+ <span>{{ $t('password') }}</span>
+ <template #prefix><fa :icon="faLock"/></template>
+ </mk-input>
+ <footer>
+ <mk-button primary type="submit" :disabled="submitting">{{ submitting ? $t('processing') : $t('done') }}<mk-ellipsis v-if="submitting"/></mk-button>
+ </footer>
+ </div>
+</form>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '../components/ui/button.vue';
+import MkInput from '../components/ui/input.vue';
+import { host } from '../config';
+import i18n from '../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ MkInput,
+ },
+
+ data() {
+ return {
+ username: '',
+ password: '',
+ submitting: false,
+ host,
+ faLock
+ }
+ },
+
+ methods: {
+ submit() {
+ if (this.submitting) return;
+ this.submitting = true;
+
+ this.$root.api('admin/accounts/create', {
+ username: this.username,
+ password: this.password,
+ }).then(res => {
+ localStorage.setItem('i', res.token);
+ location.href = '/';
+ }).catch(() => {
+ this.submitting = false;
+
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('some-error')
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-setup {
+ border-radius: var(--radius);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+
+ > h1 {
+ margin: 0;
+ font-size: 1.5em;
+ text-align: center;
+ padding: 32px;
+ background: var(--accent);
+ color: #fff;
+ }
+
+ > div {
+ padding: 32px;
+ background: var(--panel);
+
+ > p {
+ margin-top: 0;
+ }
+
+ > footer {
+ > * {
+ margin: 0 auto;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/index.welcome.vue b/src/client/pages/index.welcome.vue
new file mode 100644
index 0000000000..213c3db22c
--- /dev/null
+++ b/src/client/pages/index.welcome.vue
@@ -0,0 +1,34 @@
+<template>
+<div v-if="meta" class="mk-welcome">
+ <portal to="title">{{ instanceName }}</portal>
+ <x-setup v-if="meta.requireSetup"/>
+ <x-entrance v-else/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XSetup from './index.welcome.setup.vue';
+import XEntrance from './index.welcome.entrance.vue';
+import { getInstanceName } from '../scripts/get-instance-name';
+
+export default Vue.extend({
+ components: {
+ XSetup,
+ XEntrance,
+ },
+
+ data() {
+ return {
+ meta: null,
+ instanceName: getInstanceName(),
+ }
+ },
+
+ created() {
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ });
+ }
+});
+</script>
diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/instance/announcements.vue
new file mode 100644
index 0000000000..71cec64c7b
--- /dev/null
+++ b/src/client/pages/instance/announcements.vue
@@ -0,0 +1,129 @@
+<template>
+<div class="ztgjmzrw">
+ <portal to="icon"><fa :icon="faBroadcastTower"/></portal>
+ <portal to="title">{{ $t('announcements') }}</portal>
+ <mk-button @click="add()" primary style="margin: 0 auto 16px auto;"><fa :icon="faPlus"/> {{ $t('add') }}</mk-button>
+ <section class="_section announcements">
+ <div class="_content announcement" v-for="announcement in announcements">
+ <mk-input v-model="announcement.title" style="margin-top: 8px;">
+ <span>{{ $t('title') }}</span>
+ </mk-input>
+ <mk-textarea v-model="announcement.text">
+ <span>{{ $t('text') }}</span>
+ </mk-textarea>
+ <mk-input v-model="announcement.imageUrl">
+ <span>{{ $t('imageUrl') }}</span>
+ </mk-input>
+ <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
+ <div class="buttons">
+ <mk-button class="button" inline @click="save(announcement)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ <mk-button class="button" inline @click="remove(announcement)"><fa :icon="faTrashAlt"/> {{ $t('remove') }}</mk-button>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBroadcastTower, faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faSave, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('announcements') as string
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ },
+
+ data() {
+ return {
+ announcements: [],
+ faBroadcastTower, faSave, faTrashAlt, faPlus
+ }
+ },
+
+ created() {
+ this.$root.api('admin/announcements/list').then(announcements => {
+ this.announcements = announcements;
+ });
+ },
+
+ methods: {
+ add() {
+ this.announcements.unshift({
+ id: null,
+ title: '',
+ text: '',
+ imageUrl: null
+ });
+ },
+
+ remove(announcement) {
+ this.$root.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: announcement.title }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.announcements = this.announcements.filter(x => x != announcement);
+ this.$root.api('admin/announcements/delete', announcement);
+ });
+ },
+
+ save(announcement) {
+ if (announcement.id == null) {
+ this.$root.api('admin/announcements/create', announcement).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ text: this.$t('saved')
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ } else {
+ this.$root.api('admin/announcements/update', announcement).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ text: this.$t('saved')
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+ > .announcements {
+ > .announcement {
+ > .buttons {
+ > .button:first-child {
+ margin-right: 8px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue
new file mode 100644
index 0000000000..7a69a7efe6
--- /dev/null
+++ b/src/client/pages/instance/emojis.vue
@@ -0,0 +1,253 @@
+<template>
+<div class="mk-instance-emojis">
+ <portal to="icon"><fa :icon="faLaugh"/></portal>
+ <portal to="title">{{ $t('customEmojis') }}</portal>
+ <section class="_section local">
+ <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojis') }}</div>
+ <div class="_content">
+ <input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
+ <mk-pagination :pagination="pagination" class="emojis" ref="emojis">
+ <template #empty><span>{{ $t('noCustomEmojis') }}</span></template>
+ <template #default="{items}">
+ <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selected = emoji" :class="{ selected: selected && (selected.id === emoji.id) }">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <span class="name">{{ emoji.name }}</span>
+ </div>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary @click="add()"><fa :icon="faPlus"/> {{ $t('addEmoji') }}</mk-button>
+ <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button>
+ </div>
+ </section>
+ <section class="_section remote">
+ <div class="_title"><fa :icon="faLaugh"/> {{ $t('customEmojisOfRemote') }}</div>
+ <div class="_content">
+ <mk-input v-model="host" :debounce="true" style="margin-top: 0;"><span>{{ $t('host') }}</span></mk-input>
+ <mk-pagination :pagination="remotePagination" class="emojis" ref="remoteEmojis">
+ <template #empty><span>{{ $t('noCustomEmojis') }}</span></template>
+ <template #default="{items}">
+ <div class="emoji" v-for="(emoji, i) in items" :key="emoji.id" :data-index="i" @click="selectedRemote = emoji" :class="{ selected: selectedRemote && (selectedRemote.id === emoji.id) }">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <span class="name">{{ emoji.name }}</span>
+ <span class="host">{{ emoji.host }}</span>
+ </div>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary :disabled="selectedRemote == null" @click="im()"><fa :icon="faPlus"/> {{ $t('import') }}</mk-button>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt, faLaugh } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import { apiUrl } from '../../config';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: `${this.$t('customEmojis')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ name: null,
+ selected: null,
+ selectedRemote: null,
+ host: '',
+ pagination: {
+ endpoint: 'admin/emoji/list',
+ limit: 10,
+ },
+ remotePagination: {
+ endpoint: 'admin/emoji/list-remote',
+ limit: 10,
+ params: () => ({
+ host: this.host ? this.host : null
+ })
+ },
+ faTrashAlt, faPlus, faLaugh
+ }
+ },
+
+ watch: {
+ host() {
+ this.$refs.remoteEmojis.reload();
+ }
+ },
+
+ methods: {
+ async add() {
+ const { canceled: canceled, result: name } = await this.$root.dialog({
+ title: this.$t('emojiName'),
+ input: true
+ });
+ if (canceled) return;
+
+ this.name = name;
+
+ (this.$refs.file as any).click();
+ },
+
+ onChangeFile() {
+ const [file] = Array.from((this.$refs.file as any).files);
+ if (file == null) return;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('name', this.name);
+ data.append('i', this.$store.state.i.token);
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ text: this.$t('uploading') + '...',
+ showOkButton: false,
+ showCancelButton: false,
+ cancelableByBgClick: false
+ });
+
+ fetch(apiUrl + '/admin/emoji/add', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.$refs.emojis.reload();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ })
+ .finally(() => {
+ dialog.close();
+ });
+ },
+
+ async del() {
+ const { canceled } = await this.$root.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.selected.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ this.$root.api('admin/emoji/remove', {
+ id: this.selected.id
+ }).then(() => {
+ this.$refs.emojis.reload();
+ });
+ },
+
+ im() {
+ this.$root.api('admin/emoji/copy', {
+ emojiId: this.selectedRemote.id,
+ }).then(() => {
+ this.$refs.emojis.reload();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-emojis {
+ > .local {
+ > ._content {
+ max-height: 300px;
+ overflow: auto;
+
+ > .emojis {
+ > .emoji {
+ display: flex;
+ align-items: center;
+
+ &.selected {
+ background: var(--accent);
+ box-shadow: 0 0 0 8px var(--accent);
+ color: #fff;
+ }
+
+ > .img {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ padding: 8px;
+
+ > .name {
+ display: block;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ > .remote {
+ > ._content {
+ max-height: 300px;
+ overflow: auto;
+
+ > .emojis {
+ > .emoji {
+ display: flex;
+ align-items: center;
+
+ &.selected {
+ background: var(--accent);
+ box-shadow: 0 0 0 8px var(--accent);
+ color: #fff;
+ }
+
+ > .img {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .body {
+ padding: 0 8px;
+
+ > .name {
+ display: block;
+ }
+
+ > .host {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/federation.instance.vue b/src/client/pages/instance/federation.instance.vue
new file mode 100644
index 0000000000..a27556064a
--- /dev/null
+++ b/src/client/pages/instance/federation.instance.vue
@@ -0,0 +1,576 @@
+<template>
+<x-window @closed="() => { $emit('closed'); destroyDom(); }" :no-padding="true">
+ <template #header>{{ instance.host }}</template>
+ <div class="mk-instance-info">
+ <div class="table info">
+ <div class="row">
+ <div class="cell">
+ <div class="label">{{ $t('software') }}</div>
+ <div class="data">{{ instance.softwareName || '?' }}</div>
+ </div>
+ <div class="cell">
+ <div class="label">{{ $t('version') }}</div>
+ <div class="data">{{ instance.softwareVersion || '?' }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="table data">
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faCrosshairs" fixed-width class="icon"/>{{ $t('registeredAt') }}</div>
+ <div class="data">{{ new Date(instance.caughtAt).toLocaleString() }} (<mk-time :time="instance.caughtAt"/>)</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faCloudDownloadAlt" fixed-width class="icon"/>{{ $t('following') }}</div>
+ <div class="data clickable" @click="showFollowing()">{{ instance.followingCount | number }}</div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faCloudUploadAlt" fixed-width class="icon"/>{{ $t('followers') }}</div>
+ <div class="data clickable" @click="showFollowers()">{{ instance.followersCount | number }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faUsers" fixed-width class="icon"/>{{ $t('users') }}</div>
+ <div class="data clickable" @click="showUsers()">{{ instance.usersCount | number }}</div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faPencilAlt" fixed-width class="icon"/>{{ $t('notes') }}</div>
+ <div class="data">{{ instance.notesCount | number }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faFileImage" fixed-width class="icon"/>{{ $t('files') }}</div>
+ <div class="data">{{ instance.driveFiles | number }}</div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faDatabase" fixed-width class="icon"/>{{ $t('storageUsage') }}</div>
+ <div class="data">{{ instance.driveUsage | bytes }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faLongArrowAltUp" fixed-width class="icon"/>{{ $t('latestRequestSentAt') }}</div>
+ <div class="data"><mk-time v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+ </div>
+ <div class="cell">
+ <div class="label"><fa :icon="faTrafficLight" fixed-width class="icon"/>{{ $t('latestStatus') }}</div>
+ <div class="data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="cell">
+ <div class="label"><fa :icon="faLongArrowAltDown" fixed-width class="icon"/>{{ $t('latestRequestReceivedAt') }}</div>
+ <div class="data"><mk-time v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+ </div>
+ </div>
+ </div>
+ <div class="chart">
+ <div class="header">
+ <span class="label">{{ $t('charts') }}</span>
+ <div class="selects">
+ <mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
+ <option value="requests">{{ $t('_instanceCharts.requests') }}</option>
+ <option value="users">{{ $t('_instanceCharts.users') }}</option>
+ <option value="users-total">{{ $t('_instanceCharts.usersTotal') }}</option>
+ <option value="notes">{{ $t('_instanceCharts.notes') }}</option>
+ <option value="notes-total">{{ $t('_instanceCharts.notesTotal') }}</option>
+ <option value="ff">{{ $t('_instanceCharts.ff') }}</option>
+ <option value="ff-total">{{ $t('_instanceCharts.ffTotal') }}</option>
+ <option value="drive-usage">{{ $t('_instanceCharts.cacheSize') }}</option>
+ <option value="drive-usage-total">{{ $t('_instanceCharts.cacheSizeTotal') }}</option>
+ <option value="drive-files">{{ $t('_instanceCharts.files') }}</option>
+ <option value="drive-files-total">{{ $t('_instanceCharts.filesTotal') }}</option>
+ </mk-select>
+ <mk-select v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $t('perHour') }}</option>
+ <option value="day">{{ $t('perDay') }}</option>
+ </mk-select>
+ </div>
+ </div>
+ <div class="chart">
+ <canvas ref="chart"></canvas>
+ </div>
+ </div>
+ <div class="operations">
+ <span class="label">{{ $t('operations') }}</span>
+ <mk-switch v-model="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</mk-switch>
+ <mk-switch v-model="isBlocked" class="switch">{{ $t('blockThisInstance') }}</mk-switch>
+ </div>
+ <details class="metadata">
+ <summary class="label">{{ $t('metadata') }}</summary>
+ <pre><code>{{ JSON.stringify(instance.metadata, null, 2) }}</code></pre>
+ </details>
+ </div>
+</x-window>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown } from '@fortawesome/free-solid-svg-icons';
+import XWindow from '../../components/window.vue';
+import MkUsersDialog from '../../components/users-dialog.vue';
+import MkSelect from '../../components/ui/select.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+
+const chartLimit = 90;
+const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
+const negate = arr => arr.map(x => -x);
+const alpha = hex => {
+ 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}, 0.1)`;
+};
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XWindow,
+ MkSelect,
+ MkSwitch,
+ },
+
+ props: {
+ instance: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ meta: null,
+ isSuspended: false,
+ isBlocked: false,
+ now: null,
+ chart: null,
+ chartInstance: null,
+ chartSrc: 'requests',
+ chartSpan: 'hour',
+ faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown
+ };
+ },
+
+ computed: {
+ data(): any {
+ if (this.chart == null) return null;
+ switch (this.chartSrc) {
+ case 'requests': return this.requestsChart();
+ case 'users': return this.usersChart(false);
+ case 'users-total': return this.usersChart(true);
+ case 'notes': return this.notesChart(false);
+ case 'notes-total': return this.notesChart(true);
+ case 'ff': return this.ffChart(false);
+ case 'ff-total': return this.ffChart(true);
+ case 'drive-usage': return this.driveUsageChart(false);
+ case 'drive-usage-total': return this.driveUsageChart(true);
+ case 'drive-files': return this.driveFilesChart(false);
+ case 'drive-files-total': return this.driveFilesChart(true);
+ }
+ },
+
+ stats(): any[] {
+ const stats =
+ this.chartSpan == 'day' ? this.chart.perDay :
+ this.chartSpan == 'hour' ? this.chart.perHour :
+ null;
+
+ return stats;
+ }
+ },
+
+ watch: {
+ isSuspended() {
+ this.$root.api('admin/federation/update-instance', {
+ host: this.instance.host,
+ isSuspended: this.isSuspended
+ });
+ },
+
+ isBlocked() {
+ this.$root.api('admin/update-meta', {
+ blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
+ });
+ },
+
+ chartSrc() {
+ this.renderChart();
+ },
+
+ chartSpan() {
+ this.renderChart();
+ }
+ },
+
+ async created() {
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ this.isSuspended = this.instance.isSuspended;
+ this.isBlocked = this.meta.blockedHosts.includes(this.instance.host);
+ });
+
+ this.now = new Date();
+
+ const [perHour, perDay] = await Promise.all([
+ this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }),
+ ]);
+
+ const chart = {
+ perHour: perHour,
+ perDay: perDay
+ };
+
+ this.chart = chart;
+
+ this.renderChart();
+ },
+
+ methods: {
+ setSrc(src) {
+ this.chartSrc = src;
+ },
+
+ renderChart() {
+ if (this.chartInstance) {
+ this.chartInstance.destroy();
+ }
+
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+ this.chartInstance = new Chart(this.$refs.chart, {
+ type: 'line',
+ data: {
+ labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
+ datasets: this.data.series.map(x => ({
+ label: x.name,
+ data: x.data.slice().reverse(),
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: x.color,
+ backgroundColor: alpha(x.color),
+ }))
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+ },
+
+ getDate(ago: number) {
+ const y = this.now.getFullYear();
+ const m = this.now.getMonth();
+ const d = this.now.getDate();
+ const h = this.now.getHours();
+
+ return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+ },
+
+ format(arr) {
+ return arr;
+ },
+
+ requestsChart(): any {
+ return {
+ series: [{
+ name: 'In',
+ color: '#008FFB',
+ data: this.format(this.stats.requests.received)
+ }, {
+ name: 'Out (succ)',
+ color: '#00E396',
+ data: this.format(this.stats.requests.succeeded)
+ }, {
+ name: 'Out (fail)',
+ color: '#FEB019',
+ data: this.format(this.stats.requests.failed)
+ }]
+ };
+ },
+
+ usersChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Users',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.users.total
+ : sum(this.stats.users.inc, negate(this.stats.users.dec))
+ )
+ }]
+ };
+ },
+
+ notesChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Notes',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.notes.total
+ : sum(this.stats.notes.inc, negate(this.stats.notes.dec))
+ )
+ }]
+ };
+ },
+
+ ffChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Following',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.following.total
+ : sum(this.stats.following.inc, negate(this.stats.following.dec))
+ )
+ }, {
+ name: 'Followers',
+ color: '#00E396',
+ data: this.format(total
+ ? this.stats.followers.total
+ : sum(this.stats.followers.inc, negate(this.stats.followers.dec))
+ )
+ }]
+ };
+ },
+
+ driveUsageChart(total: boolean): any {
+ return {
+ bytes: true,
+ series: [{
+ name: 'Drive usage',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.drive.totalUsage
+ : sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage))
+ )
+ }]
+ };
+ },
+
+ driveFilesChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Drive files',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.drive.totalFiles
+ : sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles))
+ )
+ }]
+ };
+ },
+
+ showFollowing() {
+ this.$root.new(MkUsersDialog, {
+ title: this.$t('instanceFollowing'),
+ pagination: {
+ endpoint: 'federation/following',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ },
+ extract: item => item.follower
+ });
+ },
+
+ showFollowers() {
+ this.$root.new(MkUsersDialog, {
+ title: this.$t('instanceFollowers'),
+ pagination: {
+ endpoint: 'federation/followers',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ },
+ extract: item => item.followee
+ });
+ },
+
+ showUsers() {
+ this.$root.new(MkUsersDialog, {
+ title: this.$t('instanceUsers'),
+ pagination: {
+ endpoint: 'federation/users',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ }
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-info {
+ overflow: auto;
+
+ > .table {
+ padding: 0 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px;
+ }
+
+ > .row {
+ display: flex;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > .cell {
+ flex: 1;
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+
+ > .icon {
+ margin-right: 4px;
+ display: none;
+ }
+ }
+
+ > .data.clickable {
+ color: var(--accent);
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ > .data {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ margin-top: 8px;
+ padding-top: 8px;
+ }
+ }
+
+ > .chart {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ margin-top: 8px;
+ padding-top: 8px;
+ }
+
+ > .header {
+ padding: 0 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .selects {
+ display: flex;
+ }
+ }
+
+ > .chart {
+ padding: 0 16px;
+
+ @media (max-width: 500px) {
+ padding: 0;
+ }
+ }
+ }
+
+ > .operations {
+ padding: 16px 32px 16px 32px;
+ margin-top: 8px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ padding: 8px 16px 8px 16px;
+ margin-top: 0;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .switch {
+ margin: 16px 0;
+ }
+ }
+
+ > .metadata {
+ padding: 16px 32px 16px 32px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ padding: 8px 16px 8px 16px;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > pre > code {
+ display: block;
+ max-height: 200px;
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue
new file mode 100644
index 0000000000..224ff72a9f
--- /dev/null
+++ b/src/client/pages/instance/federation.vue
@@ -0,0 +1,165 @@
+<template>
+<div class="mk-federation">
+ <section class="_section instances">
+ <div class="_title"><fa :icon="faGlobe"/> {{ $t('instances') }}</div>
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <mk-input v-model="host" :debounce="true" style="margin: 0; flex: 1;"><span>{{ $t('host') }}</span></mk-input>
+ <mk-select v-model="state" style="margin: 0;">
+ <option value="all">{{ $t('all') }}</option>
+ <option value="federating">{{ $t('federating') }}</option>
+ <option value="subscribing">{{ $t('subscribing') }}</option>
+ <option value="publishing">{{ $t('publishing') }}</option>
+ <option value="suspended">{{ $t('suspended') }}</option>
+ <option value="blocked">{{ $t('blocked') }}</option>
+ <option value="notResponding">{{ $t('notResponding') }}</option>
+ </mk-select>
+ </div>
+ </div>
+ <div class="_content">
+ <mk-pagination :pagination="pagination" #default="{items}" class="instances" ref="instances" :key="host + state">
+ <div class="instance" v-for="(instance, i) in items" :key="instance.id" :data-index="i" @click="info(instance)">
+ <div class="host"><fa :icon="faCircle" class="indicator" :class="getStatus(instance)"/><b>{{ instance.host }}</b></div>
+ <div class="status">
+ <span class="sub" v-if="instance.followersCount > 0"><fa :icon="faCaretDown" class="icon"/>Sub</span>
+ <span class="sub" v-else><fa :icon="faCaretDown" class="icon"/>-</span>
+ <span class="pub" v-if="instance.followingCount > 0"><fa :icon="faCaretUp" class="icon"/>Pub</span>
+ <span class="pub" v-else><fa :icon="faCaretUp" class="icon"/>-</span>
+ <span class="lastCommunicatedAt"><fa :icon="faExchangeAlt" class="icon"/><mk-time :time="instance.lastCommunicatedAt"/></span>
+ <span class="latestStatus"><fa :icon="faTrafficLight" class="icon"/>{{ instance.latestStatus || '-' }}</span>
+ </div>
+ </div>
+ </mk-pagination>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkSelect from '../../components/ui/select.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import MkInstanceInfo from './federation.instance.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('federation') as string
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ host: '',
+ state: 'federating',
+ sort: '+pubSub',
+ pagination: {
+ endpoint: 'federation/instances',
+ limit: 10,
+ offsetMode: true,
+ params: () => ({
+ sort: this.sort,
+ host: this.host != '' ? this.host : null,
+ ...(
+ this.state === 'federating' ? { federating: true } :
+ this.state === 'subscribing' ? { subscribing: true } :
+ this.state === 'publishing' ? { publishing: true } :
+ this.state === 'suspended' ? { suspended: true } :
+ this.state === 'blocked' ? { blocked: true } :
+ this.state === 'notResponding' ? { notResponding: true } :
+ {})
+ })
+ },
+ faGlobe, faCircle, faExchangeAlt, faCaretDown, faCaretUp, faTrafficLight
+ }
+ },
+
+ watch: {
+ host() {
+ this.$refs.instances.reload();
+ },
+ state() {
+ this.$refs.instances.reload();
+ }
+ },
+
+ methods: {
+ getStatus(instance) {
+ if (instance.isSuspended) return 'off';
+ if (instance.isNotResponding) return 'red';
+ return 'green';
+ },
+
+ info(instance) {
+ this.$root.new(MkInstanceInfo, {
+ instance: instance
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-federation {
+ > .instances {
+ > ._content {
+ > .instances {
+ > .instance {
+ cursor: pointer;
+
+ > .host {
+ > .indicator {
+ font-size: 70%;
+ vertical-align: baseline;
+ margin-right: 4px;
+
+ &.green {
+ color: #49c5ba;
+ }
+
+ &.yellow {
+ color: #c5a549;
+ }
+
+ &.red {
+ color: #c54949;
+ }
+
+ &.off {
+ color: rgba(0, 0, 0, 0.5);
+ }
+ }
+ }
+
+ > .status {
+ display: flex;
+ align-items: center;
+ font-size: 90%;
+
+ > span {
+ flex: 1;
+
+ > .icon {
+ margin-right: 6px;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/files.vue b/src/client/pages/instance/files.vue
new file mode 100644
index 0000000000..e7475e94c1
--- /dev/null
+++ b/src/client/pages/instance/files.vue
@@ -0,0 +1,54 @@
+<template>
+<section class="_section">
+ <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
+ <div class="_content">
+ <mk-button primary @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearCachedFiles') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCloud } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: `${this.$t('files')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ faTrashAlt, faCloud
+ }
+ },
+
+ methods: {
+ clear() {
+ this.$root.dialog({
+ type: 'warning',
+ text: this.$t('clearCachedFilesConfirm'),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ this.$root.api('admin/drive/clean-remote-files', {}).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue
new file mode 100644
index 0000000000..5301fc7e01
--- /dev/null
+++ b/src/client/pages/instance/index.vue
@@ -0,0 +1,393 @@
+<template>
+<div v-if="meta" class="mk-instance-page">
+ <portal to="icon"><fa :icon="faServer"/></portal>
+ <portal to="title">{{ $t('instance') }}</portal>
+
+ <section class="_section info">
+ <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div>
+ <div class="_content">
+ <mk-input v-model="name" style="margin-top: 8px;">{{ $t('instanceName') }}</mk-input>
+ <mk-textarea v-model="description">{{ $t('instanceDescription') }}</mk-textarea>
+ <mk-input v-model="iconUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('iconUrl') }}</mk-input>
+ <mk-input v-model="bannerUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</mk-input>
+ <mk-input v-model="tosUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('tosUrl') }}</mk-input>
+ <mk-input v-model="maintainerName">{{ $t('maintainerName') }}</mk-input>
+ <mk-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</mk-input>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section info">
+ <div class="_content">
+ <mk-switch v-model="enableLocalTimeline" @change="save()">{{ $t('enableLocalTimeline') }}</mk-switch>
+ <mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch>
+ <mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info>
+ </div>
+ </section>
+
+ <section class="_section info">
+ <div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div>
+ <div class="_content">
+ <mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch>
+ <mk-button v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
+ <div class="_content">
+ <mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
+ <template v-if="enableRecaptcha">
+ <mk-info>{{ $t('recaptcha-info') }}</mk-info>
+ <mk-info warn>{{ $t('recaptcha-info2') }}</mk-info>
+ <mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input>
+ <mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input>
+ </template>
+ </div>
+ <div class="_content" v-if="enableRecaptcha && recaptchaSiteKey">
+ <header>{{ $t('preview') }}</header>
+ <div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div>
+ <div class="_content">
+ <mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworker-info') }}</template></mk-switch>
+ <template v-if="enableServiceWorker">
+ <mk-info>{{ $t('vapid-info') }}<br><code>npm i web-push -g<br>web-push generate-vapid-keys</code></mk-info>
+ <mk-horizon-group inputs class="fit-bottom">
+ <mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-publickey') }}</mk-input>
+ <mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>{{ $t('vapid-privatekey') }}</mk-input>
+ </mk-horizon-group>
+ </template>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div>
+ <div class="_content">
+ <mk-textarea v-model="pinnedUsers" style="margin-top: 0;">
+ <template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template>
+ </mk-textarea>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
+ <div class="_content">
+ <mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch>
+ <mk-switch v-model="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></mk-switch>
+ <mk-input v-model="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
+ <mk-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div>
+ <div class="_content">
+ <mk-input v-model="proxyAccount" style="margin: 0;"><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div>
+ <div class="_content">
+ <mk-textarea v-model="blockedHosts" style="margin-top: 0;">
+ <template #desc>{{ $t('blockedInstancesDescription') }}</template>
+ </mk-textarea>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section">
+ <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
+ <div class="_content">
+ <header><fa :icon="faTwitter"/> {{ $t('twitter-integration-config') }}</header>
+ <mk-switch v-model="enableTwitterIntegration">{{ $t('enable-twitter-integration') }}</mk-switch>
+ <template v-if="enableTwitterIntegration">
+ <mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-key') }}</mk-input>
+ <mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('twitter-integration-consumer-secret') }}</mk-input>
+ <mk-info>{{ $t('twitter-integration-info', { url: `${url}/api/tw/cb` }) }}</mk-info>
+ </template>
+ </div>
+ <div class="_content">
+ <header><fa :icon="faGithub"/> {{ $t('github-integration-config') }}</header>
+ <mk-switch v-model="enableGithubIntegration">{{ $t('enable-github-integration') }}</mk-switch>
+ <template v-if="enableGithubIntegration">
+ <mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-id') }}</mk-input>
+ <mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('github-integration-client-secret') }}</mk-input>
+ <mk-info>{{ $t('github-integration-info', { url: `${url}/api/gh/cb` }) }}</mk-info>
+ </template>
+ </div>
+ <div class="_content">
+ <header><fa :icon="faDiscord"/> {{ $t('discord-integration-config') }}</header>
+ <mk-switch v-model="enableDiscordIntegration">{{ $t('enable-discord-integration') }}</mk-switch>
+ <template v-if="enableDiscordIntegration">
+ <mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-id') }}</mk-input>
+ <mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>{{ $t('discord-integration-client-secret') }}</mk-input>
+ <mk-info>{{ $t('discord-integration-info', { url: `${url}/api/dc/cb` }) }}</mk-info>
+ </template>
+ </div>
+ <div class="_footer">
+ <mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section info">
+ <div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div>
+ <div class="_content table" v-if="stats">
+ <div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div>
+ <div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div>
+ </div>
+ <div class="_content table">
+ <div><b>Misskey</b><span>v{{ version }}</span></div>
+ </div>
+ <div class="_content table" v-if="serverInfo">
+ <div><b>Node.js</b><span>{{ serverInfo.node }}</span></div>
+ <div><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div>
+ <div><b>Redis</b><span>v{{ serverInfo.redis }}</span></div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons';
+import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import MkInfo from '../../components/ui/info.vue';
+import MkUserSelect from '../../components/user-select.vue';
+import { version } from '../../config';
+import i18n from '../../i18n';
+import getAcct from '../../../misc/acct/render';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.$t('instance') as string
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkSwitch,
+ MkInfo,
+ },
+
+ data() {
+ return {
+ version,
+ meta: null,
+ stats: null,
+ serverInfo: null,
+ proxyAccount: null,
+ cacheRemoteFiles: false,
+ proxyRemoteFiles: false,
+ localDriveCapacityMb: 0,
+ remoteDriveCapacityMb: 0,
+ blockedHosts: '',
+ pinnedUsers: '',
+ maintainerName: null,
+ maintainerEmail: null,
+ name: null,
+ description: null,
+ tosUrl: null,
+ bannerUrl: null,
+ iconUrl: null,
+ enableRegistration: false,
+ enableLocalTimeline: false,
+ enableGlobalTimeline: false,
+ enableRecaptcha: false,
+ recaptchaSiteKey: null,
+ recaptchaSecretKey: null,
+ enableServiceWorker: false,
+ swPublicKey: null,
+ swPrivateKey: null,
+ enableTwitterIntegration: false,
+ twitterConsumerKey: null,
+ twitterConsumerSecret: null,
+ enableGithubIntegration: false,
+ githubClientId: null,
+ githubClientSecret: null,
+ enableDiscordIntegration: false,
+ discordClientId: null,
+ discordClientSecret: null,
+ faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt
+ }
+ },
+
+ created() {
+ this.$root.getMeta().then(meta => {
+ this.meta = meta;
+ this.name = this.meta.name;
+ this.description = this.meta.description;
+ this.tosUrl = this.meta.tosUrl;
+ this.bannerUrl = this.meta.bannerUrl;
+ this.iconUrl = this.meta.iconUrl;
+ this.maintainerName = this.meta.maintainerName;
+ this.maintainerEmail = this.meta.maintainerEmail;
+ this.enableRegistration = !this.meta.disableRegistration;
+ this.enableLocalTimeline = !this.meta.disableLocalTimeline;
+ this.enableGlobalTimeline = !this.meta.disableGlobalTimeline;
+ this.enableRecaptcha = this.meta.enableRecaptcha;
+ this.recaptchaSiteKey = this.meta.recaptchaSiteKey;
+ this.recaptchaSecretKey = this.meta.recaptchaSecretKey;
+ this.proxyAccount = this.meta.proxyAccount;
+ this.cacheRemoteFiles = this.meta.cacheRemoteFiles;
+ this.proxyRemoteFiles = this.meta.proxyRemoteFiles;
+ this.localDriveCapacityMb = this.meta.driveCapacityPerLocalUserMb;
+ this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb;
+ this.blockedHosts = this.meta.blockedHosts.join('\n');
+ this.pinnedUsers = this.meta.pinnedUsers.join('\n');
+ this.enableServiceWorker = this.meta.enableServiceWorker;
+ this.swPublicKey = this.meta.swPublickey;
+ this.swPrivateKey = this.meta.swPrivateKey;
+ this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
+ this.twitterConsumerKey = this.meta.twitterConsumerKey;
+ this.twitterConsumerSecret = this.meta.twitterConsumerSecret;
+ this.enableGithubIntegration = this.meta.enableGithubIntegration;
+ this.githubClientId = this.meta.githubClientId;
+ this.githubClientSecret = this.meta.githubClientSecret;
+ this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
+ this.discordClientId = this.meta.discordClientId;
+ this.discordClientSecret = this.meta.discordClientSecret;
+ });
+
+ this.$root.api('admin/server-info').then(res => {
+ this.serverInfo = res;
+ });
+
+ this.$root.api('stats').then(res => {
+ this.stats = res;
+ });
+ },
+
+ mounted() {
+ const renderRecaptchaPreview = () => {
+ if (!(window as any).grecaptcha) return;
+ if (!this.$refs.recaptcha) return;
+ if (!this.recaptchaSiteKey) return;
+ (window as any).grecaptcha.render(this.$refs.recaptcha, {
+ sitekey: this.recaptchaSiteKey
+ });
+ };
+ window.onRecaotchaLoad = () => {
+ renderRecaptchaPreview();
+ };
+ const head = document.getElementsByTagName('head')[0];
+ const script = document.createElement('script');
+ script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad');
+ head.appendChild(script);
+ this.$watch('enableRecaptcha', () => {
+ renderRecaptchaPreview();
+ });
+ this.$watch('recaptchaSiteKey', () => {
+ renderRecaptchaPreview();
+ });
+ },
+
+ methods: {
+ addPinUser() {
+ this.$root.new(MkUserSelect, {}).$once('selected', user => {
+ this.pinnedUsers = this.pinnedUsers.trim();
+ this.pinnedUsers += '\n@' + getAcct(user);
+ this.pinnedUsers = this.pinnedUsers.trim();
+ });
+ },
+
+ save(withDialog = false) {
+ this.$root.api('admin/update-meta', {
+ name: this.name,
+ description: this.description,
+ tosUrl: this.tosUrl,
+ bannerUrl: this.bannerUrl,
+ iconUrl: this.iconUrl,
+ maintainerName: this.maintainerName,
+ maintainerEmail: this.maintainerEmail,
+ disableRegistration: !this.enableRegistration,
+ disableLocalTimeline: !this.enableLocalTimeline,
+ disableGlobalTimeline: !this.enableGlobalTimeline,
+ enableRecaptcha: this.enableRecaptcha,
+ recaptchaSiteKey: this.recaptchaSiteKey,
+ recaptchaSecretKey: this.recaptchaSecretKey,
+ proxyAccount: this.proxyAccount,
+ cacheRemoteFiles: this.cacheRemoteFiles,
+ proxyRemoteFiles: this.proxyRemoteFiles,
+ localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
+ remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
+ blockedHosts: this.blockedHosts.split('\n') || [],
+ pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [],
+ enableServiceWorker: this.enableServiceWorker,
+ swPublicKey: this.swPublicKey,
+ swPrivateKey: this.swPrivateKey,
+ enableTwitterIntegration: this.enableTwitterIntegration,
+ twitterConsumerKey: this.twitterConsumerKey,
+ twitterConsumerSecret: this.twitterConsumerSecret,
+ enableGithubIntegration: this.enableGithubIntegration,
+ githubClientId: this.githubClientId,
+ githubClientSecret: this.githubClientSecret,
+ enableDiscordIntegration: this.enableDiscordIntegration,
+ discordClientId: this.discordClientId,
+ discordClientSecret: this.discordClientSecret,
+ }).then(() => {
+ if (withDialog) {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-page {
+ > .info {
+ > .table {
+ > div {
+ display: flex;
+
+ > * {
+ flex: 1;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/monitor.vue b/src/client/pages/instance/monitor.vue
new file mode 100644
index 0000000000..3f3ce6d73a
--- /dev/null
+++ b/src/client/pages/instance/monitor.vue
@@ -0,0 +1,381 @@
+<template>
+<div class="mk-instance-monitor">
+ <section class="_section">
+ <div class="_title"><fa :icon="faMicrochip"/> {{ $t('cpuAndMemory') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <canvas ref="cpumem"></canvas>
+ </div>
+ <div class="_content" v-if="serverInfo">
+ <div class="table">
+ <div class="row">
+ <div class="cell"><div class="label">CPU</div>{{ serverInfo.cpu.model }}</div>
+ </div>
+ <div class="row">
+ <div class="cell"><div class="label">MEM total</div>{{ serverInfo.mem.total | bytes }}</div>
+ <div class="cell"><div class="label">MEM used</div>{{ memUsage | bytes }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ <div class="cell"><div class="label">MEM free</div>{{ serverInfo.mem.total - memUsage | bytes }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <section class="_section">
+ <div class="_title"><fa :icon="faHdd"/> {{ $t('disk') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <canvas ref="disk"></canvas>
+ </div>
+ <div class="_content" v-if="serverInfo">
+ <div class="table">
+ <div class="row">
+ <div class="cell"><div class="label">Disk total</div>{{ serverInfo.fs.total | bytes }}</div>
+ <div class="cell"><div class="label">Disk used</div>{{ serverInfo.fs.used | bytes }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ <div class="cell"><div class="label">Disk free</div>{{ serverInfo.fs.total - serverInfo.fs.used | bytes }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </section>
+ <section class="_section">
+ <div class="_title"><fa :icon="faExchangeAlt"/> {{ $t('network') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <canvas ref="net"></canvas>
+ </div>
+ <div class="_content" v-if="serverInfo">
+ <div class="table">
+ <div class="row">
+ <div class="cell"><div class="label">Interface</div>{{ serverInfo.net.interface }}</div>
+ </div>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faTachometerAlt, faExchangeAlt, faMicrochip, faHdd } from '@fortawesome/free-solid-svg-icons';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+
+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 Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: `${this.$t('monitor')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ },
+
+ data() {
+ return {
+ connection: null,
+ serverInfo: null,
+ memUsage: 0,
+ chartCpuMem: null,
+ chartNet: null,
+ faTachometerAlt, faExchangeAlt, faMicrochip, faHdd
+ }
+ },
+
+ mounted() {
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ this.chartCpuMem = new Chart(this.$refs.cpumem, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'CPU',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#86b300',
+ backgroundColor: alpha('#86b300', 0.1),
+ data: []
+ }, {
+ label: 'MEM (active)',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ backgroundColor: alpha('#935dbf', 0.02),
+ data: []
+ }, {
+ label: 'MEM (used)',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ borderDash: [5, 5],
+ fill: false,
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ max: 100
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.chartNet = new Chart(this.$refs.net, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'In',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Out',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.chartDisk = new Chart(this.$refs.disk, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Read',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Write',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.$root.api('admin/server-info', {}).then(res => {
+ this.serverInfo = res;
+
+ this.connection = this.$root.stream.useSharedConnection('serverStats');
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 150
+ });
+ });
+ },
+
+ beforeDestroy() {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ this.connection.dispose();
+ },
+
+ methods: {
+ onStats(stats) {
+ const cpu = (stats.cpu * 100).toFixed(0);
+ const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+ const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+ this.memUsage = stats.mem.active;
+
+ this.chartCpuMem.data.labels.push('');
+ this.chartCpuMem.data.datasets[0].data.push(cpu);
+ this.chartCpuMem.data.datasets[1].data.push(memActive);
+ this.chartCpuMem.data.datasets[2].data.push(memUsed);
+ this.chartNet.data.labels.push('');
+ this.chartNet.data.datasets[0].data.push(stats.net.rx);
+ this.chartNet.data.datasets[1].data.push(stats.net.tx);
+ this.chartDisk.data.labels.push('');
+ this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+ this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+ if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+ this.chartCpuMem.data.labels.shift();
+ this.chartCpuMem.data.datasets[0].data.shift();
+ this.chartCpuMem.data.datasets[1].data.shift();
+ this.chartCpuMem.data.datasets[2].data.shift();
+ this.chartNet.data.labels.shift();
+ this.chartNet.data.datasets[0].data.shift();
+ this.chartNet.data.datasets[1].data.shift();
+ this.chartDisk.data.labels.shift();
+ this.chartDisk.data.datasets[0].data.shift();
+ this.chartDisk.data.datasets[1].data.shift();
+ }
+ this.chartCpuMem.update();
+ this.chartNet.update();
+ this.chartDisk.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of statsLog.reverse()) {
+ this.onStats(stats);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-monitor {
+ > section {
+ > ._content {
+ > .table {
+ > .row {
+ display: flex;
+
+ &:not(:last-child) {
+ margin-bottom: 16px;
+
+ @media (max-width: 500px) {
+ margin-bottom: 8px;
+ }
+ }
+
+ > .cell {
+ flex: 1;
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+
+ > .icon {
+ margin-right: 4px;
+ display: none;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/queue.queue.vue b/src/client/pages/instance/queue.queue.vue
new file mode 100644
index 0000000000..cc542b176f
--- /dev/null
+++ b/src/client/pages/instance/queue.queue.vue
@@ -0,0 +1,204 @@
+<template>
+<section class="_section mk-queue-queue">
+ <div class="_title"><slot name="title"></slot></div>
+ <div class="_content status">
+ <div class="cell"><div class="label">Process</div>{{ activeSincePrevTick | number }}</div>
+ <div class="cell"><div class="label">Active</div>{{ active | number }}</div>
+ <div class="cell"><div class="label">Waiting</div>{{ waiting | number }}</div>
+ <div class="cell"><div class="label">Delayed</div>{{ delayed | number }}</div>
+ </div>
+ <div class="_content" style="margin-bottom: -8px;">
+ <canvas ref="chart"></canvas>
+ </div>
+ <div class="_content" style="max-height: 180px; overflow: auto;">
+ <sequential-entrance :delay="15" v-if="jobs.length > 0">
+ <div v-for="(job, i) in jobs" :key="job[0]" :data-index="i">
+ <span>{{ job[0] }}</span>
+ <span style="margin-left: 8px; opacity: 0.7;">({{ job[1] | number }} jobs)</span>
+ </div>
+ </sequential-entrance>
+ <span v-else style="opacity: 0.5;">{{ $t('noJobs') }}</span>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+
+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 Vue.extend({
+ i18n,
+
+ props: {
+ domain: {
+ required: true
+ },
+ connection: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ chart: null,
+ jobs: [],
+ activeSincePrevTick: 0,
+ active: 0,
+ waiting: 0,
+ delayed: 0,
+ }
+ },
+
+ mounted() {
+ this.fetchJobs();
+
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ this.chart = new Chart(this.$refs.chart, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Process',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#00E396',
+ backgroundColor: alpha('#00E396', 0.1),
+ data: []
+ }, {
+ label: 'Active',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#00BCD4',
+ backgroundColor: alpha('#00BCD4', 0.1),
+ data: []
+ }, {
+ label: 'Waiting',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#FFB300',
+ backgroundColor: alpha('#FFB300', 0.1),
+ data: []
+ }, {
+ label: 'Delayed',
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: '#E53935',
+ borderDash: [5, 5],
+ fill: false,
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 8,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false,
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ },
+
+ beforeDestroy() {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ },
+
+ methods: {
+ onStats(stats) {
+ this.activeSincePrevTick = stats[this.domain].activeSincePrevTick;
+ this.active = stats[this.domain].active;
+ this.waiting = stats[this.domain].waiting;
+ this.delayed = stats[this.domain].delayed;
+ this.chart.data.labels.push('');
+ this.chart.data.datasets[0].data.push(stats[this.domain].activeSincePrevTick);
+ this.chart.data.datasets[1].data.push(stats[this.domain].active);
+ this.chart.data.datasets[2].data.push(stats[this.domain].waiting);
+ this.chart.data.datasets[3].data.push(stats[this.domain].delayed);
+ if (this.chart.data.datasets[0].data.length > 200) {
+ this.chart.data.labels.shift();
+ this.chart.data.datasets[0].data.shift();
+ this.chart.data.datasets[1].data.shift();
+ this.chart.data.datasets[2].data.shift();
+ this.chart.data.datasets[3].data.shift();
+ }
+ this.chart.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of statsLog.reverse()) {
+ this.onStats(stats);
+ }
+ },
+
+ fetchJobs() {
+ this.$root.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => {
+ this.jobs = jobs;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-queue-queue {
+ > .status {
+ display: flex;
+
+ > .cell {
+ flex: 1;
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/instance/queue.vue
new file mode 100644
index 0000000000..b7e633081f
--- /dev/null
+++ b/src/client/pages/instance/queue.vue
@@ -0,0 +1,79 @@
+<template>
+<div>
+ <x-queue :connection="connection" domain="inbox">
+ <template #title><fa :icon="faExchangeAlt"/> In</template>
+ </x-queue>
+ <x-queue :connection="connection" domain="deliver">
+ <template #title><fa :icon="faExchangeAlt"/> Out</template>
+ </x-queue>
+ <section class="_section">
+ <div class="_content">
+ <mk-button @click="clear()"><fa :icon="faTrashAlt"/> {{ $t('clearQueue') }}</mk-button>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faExchangeAlt } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import XQueue from './queue.queue.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: `${this.$t('jobQueue')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ XQueue,
+ },
+
+ data() {
+ return {
+ connection: this.$root.stream.useSharedConnection('queueStats'),
+ faExchangeAlt, faTrashAlt
+ }
+ },
+
+ mounted() {
+ this.$nextTick(() => {
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeDestroy() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ clear() {
+ this.$root.dialog({
+ type: 'warning',
+ title: this.$t('clearQueueConfirmTitle'),
+ text: this.$t('clearQueueConfirmText'),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ this.$root.api('admin/queue/clear', {}).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/instance/stats.vue b/src/client/pages/instance/stats.vue
new file mode 100644
index 0000000000..595ad2cc3c
--- /dev/null
+++ b/src/client/pages/instance/stats.vue
@@ -0,0 +1,491 @@
+<template>
+<div class="mk-instance-stats">
+ <section class="_section">
+ <div class="_title"><fa :icon="faChartBar"/> {{ $t('statistics') }}</div>
+ <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
+ <div class="selects" style="display: flex;">
+ <mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
+ <optgroup :label="$t('federation')">
+ <option value="federation-instances">{{ $t('_charts.federationInstancesIncDec') }}</option>
+ <option value="federation-instances-total">{{ $t('_charts.federationInstancesTotal') }}</option>
+ </optgroup>
+ <optgroup :label="$t('users')">
+ <option value="users">{{ $t('_charts.usersIncDec') }}</option>
+ <option value="users-total">{{ $t('_charts.usersTotal') }}</option>
+ <option value="active-users">{{ $t('_charts.activeUsers') }}</option>
+ </optgroup>
+ <optgroup :label="$t('notes')">
+ <option value="notes">{{ $t('_charts.notesIncDec') }}</option>
+ <option value="local-notes">{{ $t('_charts.localNotesIncDec') }}</option>
+ <option value="remote-notes">{{ $t('_charts.remoteNotesIncDec') }}</option>
+ <option value="notes-total">{{ $t('_charts.notesTotal') }}</option>
+ </optgroup>
+ <optgroup :label="$t('drive')">
+ <option value="drive-files">{{ $t('_charts.filesIncDec') }}</option>
+ <option value="drive-files-total">{{ $t('_charts.filesTotal') }}</option>
+ <option value="drive">{{ $t('_charts.storageUsageIncDec') }}</option>
+ <option value="drive-total">{{ $t('_charts.storageUsageTotal') }}</option>
+ </optgroup>
+ </mk-select>
+ <mk-select v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $t('perHour') }}</option>
+ <option value="day">{{ $t('perDay') }}</option>
+ </mk-select>
+ </div>
+ <canvas ref="chart"></canvas>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faChartBar } from '@fortawesome/free-solid-svg-icons';
+import Chart from 'chart.js';
+import i18n from '../../i18n';
+import MkSelect from '../../components/ui/select.vue';
+
+const chartLimit = 90;
+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})`;
+};
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: `${this.$t('statistics')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkSelect
+ },
+
+ data() {
+ return {
+ now: null,
+ chart: null,
+ chartInstance: null,
+ chartSrc: 'notes',
+ chartSpan: 'hour',
+ faChartBar
+ }
+ },
+
+ computed: {
+ data(): any {
+ if (this.chart == null) return null;
+ switch (this.chartSrc) {
+ case 'federation-instances': return this.federationInstancesChart(false);
+ case 'federation-instances-total': return this.federationInstancesChart(true);
+ case 'users': return this.usersChart(false);
+ case 'users-total': return this.usersChart(true);
+ case 'active-users': return this.activeUsersChart();
+ case 'notes': return this.notesChart('combined');
+ case 'local-notes': return this.notesChart('local');
+ case 'remote-notes': return this.notesChart('remote');
+ case 'notes-total': return this.notesTotalChart();
+ case 'drive': return this.driveChart();
+ case 'drive-total': return this.driveTotalChart();
+ case 'drive-files': return this.driveFilesChart();
+ case 'drive-files-total': return this.driveFilesTotalChart();
+ }
+ },
+
+ stats(): any[] {
+ const stats =
+ this.chartSpan == 'day' ? this.chart.perDay :
+ this.chartSpan == 'hour' ? this.chart.perHour :
+ null;
+
+ return stats;
+ }
+ },
+
+ watch: {
+ chartSrc() {
+ this.renderChart();
+ },
+
+ chartSpan() {
+ this.renderChart();
+ }
+ },
+
+ async created() {
+ this.now = new Date();
+
+ const [perHour, perDay] = await Promise.all([Promise.all([
+ this.$root.api('charts/federation', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/users', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/active-users', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/notes', { limit: chartLimit, span: 'hour' }),
+ this.$root.api('charts/drive', { limit: chartLimit, span: 'hour' }),
+ ]), Promise.all([
+ this.$root.api('charts/federation', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/users', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/active-users', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/notes', { limit: chartLimit, span: 'day' }),
+ this.$root.api('charts/drive', { limit: chartLimit, span: 'day' }),
+ ])]);
+
+ const chart = {
+ perHour: {
+ federation: perHour[0],
+ users: perHour[1],
+ activeUsers: perHour[2],
+ notes: perHour[3],
+ drive: perHour[4],
+ },
+ perDay: {
+ federation: perDay[0],
+ users: perDay[1],
+ activeUsers: perDay[2],
+ notes: perDay[3],
+ drive: perDay[4],
+ }
+ };
+
+ this.chart = chart;
+
+ this.renderChart();
+ },
+
+ methods: {
+ renderChart() {
+ if (this.chartInstance) {
+ this.chartInstance.destroy();
+ }
+
+ Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+ this.chartInstance = new Chart(this.$refs.chart, {
+ type: 'line',
+ data: {
+ labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
+ datasets: this.data.series.map(x => ({
+ label: x.name,
+ data: x.data.slice().reverse(),
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: x.color,
+ backgroundColor: alpha(x.color, 0.1),
+ hidden: !!x.hidden
+ }))
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ xAxes: [{
+ gridLines: {
+ display: false
+ },
+ ticks: {
+ display: false
+ }
+ }],
+ yAxes: [{
+ position: 'right',
+ ticks: {
+ display: false
+ }
+ }]
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ });
+ },
+
+ getDate(ago: number) {
+ const y = this.now.getFullYear();
+ const m = this.now.getMonth();
+ const d = this.now.getDate();
+ const h = this.now.getHours();
+
+ return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+ },
+
+ format(arr) {
+ return arr;
+ },
+
+ federationInstancesChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Instances',
+ color: '#008FFB',
+ data: this.format(total
+ ? this.stats.federation.instance.total
+ : sum(this.stats.federation.instance.inc, negate(this.stats.federation.instance.dec))
+ )
+ }]
+ };
+ },
+
+ notesChart(type: string): any {
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec))
+ : sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec))
+ )
+ }, {
+ name: 'Renotes',
+ type: 'area',
+ color: '#00E396',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.diffs.renote, this.stats.notes.remote.diffs.renote)
+ : this.stats.notes[type].diffs.renote
+ )
+ }, {
+ name: 'Replies',
+ type: 'area',
+ color: '#FEB019',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.diffs.reply, this.stats.notes.remote.diffs.reply)
+ : this.stats.notes[type].diffs.reply
+ )
+ }, {
+ name: 'Normal',
+ type: 'area',
+ color: '#FF4560',
+ data: this.format(type == 'combined'
+ ? sum(this.stats.notes.local.diffs.normal, this.stats.notes.remote.diffs.normal)
+ : this.stats.notes[type].diffs.normal
+ )
+ }]
+ };
+ },
+
+ notesTotalChart(): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.notes.local.total, this.stats.notes.remote.total))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.notes.local.total)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.notes.remote.total)
+ }]
+ };
+ },
+
+ usersChart(total: boolean): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(total
+ ? sum(this.stats.users.local.total, this.stats.users.remote.total)
+ : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec), this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
+ )
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(total
+ ? this.stats.users.local.total
+ : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec))
+ )
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(total
+ ? this.stats.users.remote.total
+ : sum(this.stats.users.remote.inc, negate(this.stats.users.remote.dec))
+ )
+ }]
+ };
+ },
+
+ activeUsersChart(): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.activeUsers.local.count)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.activeUsers.remote.count)
+ }]
+ };
+ },
+
+ driveChart(): any {
+ return {
+ bytes: true,
+ series: [{
+ name: 'All',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(
+ sum(
+ this.stats.drive.local.incSize,
+ negate(this.stats.drive.local.decSize),
+ this.stats.drive.remote.incSize,
+ negate(this.stats.drive.remote.decSize)
+ )
+ )
+ }, {
+ name: 'Local +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.local.incSize)
+ }, {
+ name: 'Local -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.local.decSize))
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.remote.incSize)
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.remote.decSize))
+ }]
+ };
+ },
+
+ driveTotalChart(): any {
+ return {
+ bytes: true,
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.drive.local.totalSize, this.stats.drive.remote.totalSize))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.local.totalSize)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.remote.totalSize)
+ }]
+ };
+ },
+
+ driveFilesChart(): any {
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(
+ sum(
+ this.stats.drive.local.incCount,
+ negate(this.stats.drive.local.decCount),
+ this.stats.drive.remote.incCount,
+ negate(this.stats.drive.remote.decCount)
+ )
+ )
+ }, {
+ name: 'Local +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.local.incCount)
+ }, {
+ name: 'Local -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.local.decCount))
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(this.stats.drive.remote.incCount)
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ color: '#008FFB',
+ data: this.format(negate(this.stats.drive.remote.decCount))
+ }]
+ };
+ },
+
+ driveFilesTotalChart(): any {
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ color: '#008FFB',
+ data: this.format(sum(this.stats.drive.local.totalCount, this.stats.drive.remote.totalCount))
+ }, {
+ name: 'Local',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.local.totalCount)
+ }, {
+ name: 'Remote',
+ type: 'area',
+ color: '#008FFB',
+ hidden: true,
+ data: this.format(this.stats.drive.remote.totalCount)
+ }]
+ };
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue
new file mode 100644
index 0000000000..da59d8ce24
--- /dev/null
+++ b/src/client/pages/instance/users.vue
@@ -0,0 +1,203 @@
+<template>
+<div class="mk-instance-users">
+ <portal to="icon"><fa :icon="faUsers"/></portal>
+ <portal to="title">{{ $t('users') }}</portal>
+
+ <section class="_section lookup">
+ <div class="_title"><fa :icon="faSearch"/> {{ $t('lookup') }}</div>
+ <div class="_content">
+ <mk-input class="target" v-model="target" type="text" @enter="showUser()" style="margin-top: 0;">
+ <span>{{ $t('usernameOrUserId') }}</span>
+ </mk-input>
+ <mk-button @click="showUser()" primary><fa :icon="faSearch"/> {{ $t('lookup') }}</mk-button>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary @click="search()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button>
+ </div>
+ </section>
+
+ <section class="_section users">
+ <div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div>
+ <div class="_content _list">
+ <mk-pagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false">
+ <button class="user _button _listItem" v-for="(user, i) in items" :key="user.id" :data-index="i" @click="show(user)">
+ <mk-avatar :user="user" class="avatar"/>
+ <div class="body">
+ <mk-user-name :user="user" class="name"/>
+ <mk-acct :user="user" class="acct"/>
+ </div>
+ </button>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button inline primary @click="addUser()"><fa :icon="faPlus"/> {{ $t('addUser') }}</mk-button>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus, faUsers, faSearch } from '@fortawesome/free-solid-svg-icons';
+import parseAcct from '../../../misc/acct/parse';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import MkUserModerateDialog from '../../components/user-moderate-dialog.vue';
+import MkUserSelect from '../../components/user-select.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: `${this.$t('users')} | ${this.$t('instance')}`
+ };
+ },
+
+ components: {
+ MkButton,
+ MkInput,
+ MkPagination,
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'admin/show-users',
+ limit: 10,
+ offsetMode: true
+ },
+ target: '',
+ faPlus, faUsers, faSearch
+ }
+ },
+
+ methods: {
+ /** テキストエリアのユーザーを解決する */
+ fetchUser() {
+ return new Promise((res) => {
+ const usernamePromise = this.$root.api('users/show', parseAcct(this.target));
+ const idPromise = this.$root.api('users/show', { userId: this.target });
+ let _notFound = false;
+ const notFound = () => {
+ if (_notFound) {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('noSuchUser')
+ });
+ } else {
+ _notFound = true;
+ }
+ };
+ usernamePromise.then(res).catch(e => {
+ if (e.code === 'NO_SUCH_USER') {
+ notFound();
+ }
+ });
+ idPromise.then(res).catch(e => {
+ notFound();
+ });
+ });
+ },
+
+ /** テキストエリアから処理対象ユーザーを設定する */
+ async showUser() {
+ const user = await this.fetchUser();
+ this.$root.api('admin/show-user', { userId: user.id }).then(info => {
+ this.show(user, info);
+ });
+ this.target = '';
+ },
+
+ async addUser() {
+ const { canceled: canceled1, result: username } = await this.$root.dialog({
+ title: this.$t('username'),
+ input: true
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: password } = await this.$root.dialog({
+ title: this.$t('password'),
+ input: { type: 'password' }
+ });
+ if (canceled2) return;
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ iconOnly: true
+ });
+
+ this.$root.api('admin/accounts/create', {
+ username: username,
+ password: password,
+ }).then(res => {
+ this.$refs.users.reload();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e.id
+ });
+ }).finally(() => {
+ dialog.close();
+ });
+ },
+
+ async show(user, info) {
+ if (info == null) info = await this.$root.api('admin/show-user', { userId: user.id });
+ this.$root.new(MkUserModerateDialog, {
+ user: { ...user, ...info }
+ });
+ },
+
+ search() {
+ this.$root.new(MkUserSelect, {}).$once('selected', user => {
+ this.$root.api('admin/show-user', { userId: user.id }).then(info => {
+ this.show(user, info);
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-users {
+ > .users {
+ > ._content {
+ max-height: 300px;
+ overflow: auto;
+
+ > .users {
+ > .user {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ padding: 8px;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/mentions.vue b/src/client/pages/mentions.vue
new file mode 100644
index 0000000000..333af91734
--- /dev/null
+++ b/src/client/pages/mentions.vue
@@ -0,0 +1,46 @@
+<template>
+<div>
+ <portal to="icon"><fa :icon="faAt"/></portal>
+ <portal to="title">{{ $t('mentions') }}</portal>
+ <x-notes :pagination="pagination" :detail="true" @before="before()" @after="after()"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faAt } from '@fortawesome/free-solid-svg-icons';
+import Progress from '../scripts/loading';
+import XNotes from '../components/notes.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('mentions') as string
+ };
+ },
+
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ },
+ faAt
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/messages.vue b/src/client/pages/messages.vue
new file mode 100644
index 0000000000..1165004e97
--- /dev/null
+++ b/src/client/pages/messages.vue
@@ -0,0 +1,49 @@
+<template>
+<div>
+ <portal to="icon"><fa :icon="faEnvelope"/></portal>
+ <portal to="title">{{ $t('directNotes') }}</portal>
+ <x-notes :pagination="pagination" :detail="true" @before="before()" @after="after()"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
+import Progress from '../scripts/loading';
+import XNotes from '../components/notes.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('directNotes') as string
+ };
+ },
+
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ params: () => ({
+ visibility: 'specified'
+ })
+ },
+ faEnvelope
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/messaging-room.form.vue b/src/client/pages/messaging-room.form.vue
new file mode 100644
index 0000000000..4cdd2b1f32
--- /dev/null
+++ b/src/client/pages/messaging-room.form.vue
@@ -0,0 +1,357 @@
+<template>
+<div class="mk-messaging-form _panel"
+ @dragover.stop="onDragover"
+ @drop.stop="onDrop"
+>
+ <textarea
+ v-model="text"
+ ref="textarea"
+ @keypress="onKeypress"
+ @paste="onPaste"
+ :placeholder="$t('input-message-here')"
+ v-autocomplete="{ model: 'text' }"
+ ></textarea>
+ <div class="file" @click="file = null" v-if="file">{{ file.name }}</div>
+ <x-uploader ref="uploader" @uploaded="onUploaded"/>
+ <button class="send _button" @click="send" :disabled="!canSend || sending" :title="$t('send')">
+ <template v-if="!sending"><fa :icon="faPaperPlane"/></template><template v-if="sending"><fa icon="spinner .spin"/></template>
+ </button>
+ <button class="attach-from-local _button" @click="chooseFile" :title="$t('attach-from-local')">
+ <fa :icon="faUpload"/>
+ </button>
+ <button class="attach-from-drive _button" @click="chooseFileFromDrive" :title="$t('attach-from-drive')">
+ <fa :icon="faCloud"/>
+ </button>
+ <input ref="file" type="file" @change="onChangeFile"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPaperPlane, faUpload, faCloud } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../i18n';
+import * as autosize from 'autosize';
+import { formatTimeString } from '../../misc/format-time-string';
+
+export default Vue.extend({
+ i18n,
+ components: {
+ XUploader: () => import('../components/uploader.vue').then(m => m.default),
+ },
+ props: {
+ user: {
+ type: Object,
+ requird: false,
+ },
+ group: {
+ type: Object,
+ requird: false,
+ },
+ },
+ data() {
+ return {
+ text: null,
+ file: null,
+ sending: false,
+ faPaperPlane, faUpload, faCloud
+ };
+ },
+ computed: {
+ draftId(): string {
+ return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
+ },
+ canSend(): boolean {
+ return (this.text != null && this.text != '') || this.file != null;
+ },
+ room(): any {
+ return this.$parent;
+ }
+ },
+ watch: {
+ text() {
+ this.saveDraft();
+ },
+ file() {
+ this.saveDraft();
+
+ if (this.room.isBottom()) {
+ this.room.scrollToBottom();
+ }
+ }
+ },
+ mounted() {
+ autosize(this.$refs.textarea);
+
+ // 書きかけの投稿を復元
+ const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftId];
+ if (draft) {
+ this.text = draft.data.text;
+ this.file = draft.data.file;
+ }
+ },
+ methods: {
+ async onPaste(e: ClipboardEvent) {
+ const data = e.clipboardData;
+ const items = data.items;
+
+ if (items.length == 1) {
+ if (items[0].kind == 'file') {
+ const file = items[0].getAsFile();
+ const lio = file.name.lastIndexOf('.');
+ const ext = lio >= 0 ? file.name.slice(lio) : '';
+ const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.settings.pastedFileName).replace(/{{number}}/g, '1')}${ext}`;
+ const name = this.$store.state.settings.pasteDialog
+ ? await this.$root.dialog({
+ title: this.$t('@.post-form.enter-file-name'),
+ input: {
+ default: formatted
+ },
+ allowEmpty: false
+ }).then(({ canceled, result }) => canceled ? false : result)
+ : formatted;
+ if (name) this.upload(file, name);
+ }
+ } else {
+ if (items[0].kind == 'file') {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('only-one-file-attached')
+ });
+ }
+ }
+ },
+
+ onDragover(e) {
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
+ if (isFile || isDriveFile) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ }
+ },
+
+ onDrop(e): void {
+ // ファイルだったら
+ if (e.dataTransfer.files.length == 1) {
+ e.preventDefault();
+ this.upload(e.dataTransfer.files[0]);
+ return;
+ } else if (e.dataTransfer.files.length > 1) {
+ e.preventDefault();
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('only-one-file-attached')
+ });
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData('mk_drive_file');
+ if (driveFile != null && driveFile != '') {
+ this.file = JSON.parse(driveFile);
+ e.preventDefault();
+ }
+ //#endregion
+ },
+
+ onKeypress(e) {
+ if ((e.which == 10 || e.which == 13) && e.ctrlKey && this.canSend) {
+ this.send();
+ }
+ },
+
+ chooseFile() {
+ (this.$refs.file as any).click();
+ },
+
+ chooseFileFromDrive() {
+ this.$chooseDriveFile({
+ multiple: false
+ }).then(file => {
+ this.file = file;
+ });
+ },
+
+ onChangeFile() {
+ this.upload((this.$refs.file as any).files[0]);
+ },
+
+ upload(file: File, name?: string) {
+ (this.$refs.uploader as any).upload(file, this.$store.state.settings.uploadFolder, name);
+ },
+
+ onUploaded(file) {
+ this.file = file;
+ },
+
+ send() {
+ this.sending = true;
+ this.$root.api('messaging/messages/create', {
+ userId: this.user ? this.user.id : undefined,
+ groupId: this.group ? this.group.id : undefined,
+ text: this.text ? this.text : undefined,
+ fileId: this.file ? this.file.id : undefined
+ }).then(message => {
+ this.clear();
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ this.sending = false;
+ });
+ },
+
+ clear() {
+ this.text = '';
+ this.file = null;
+ this.deleteDraft();
+ },
+
+ saveDraft() {
+ const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+ data[this.draftId] = {
+ updatedAt: new Date(),
+ data: {
+ text: this.text,
+ file: this.file
+ }
+ }
+
+ localStorage.setItem('message_drafts', JSON.stringify(data));
+ },
+
+ deleteDraft() {
+ const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+ delete data[this.draftId];
+
+ localStorage.setItem('message_drafts', JSON.stringify(data));
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-messaging-form {
+ position: relative;
+
+ > textarea {
+ cursor: auto;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ height: 80px;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ resize: none;
+ font-size: 1em;
+ outline: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+ box-sizing: border-box;
+ color: var(--fg);
+ }
+
+ > .file {
+ padding: 8px;
+ color: #444;
+ background: #eee;
+ cursor: pointer;
+ }
+
+ > .send {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: 0;
+ padding: 16px;
+ font-size: 1em;
+ color: #aaa;
+ transition: color 0.1s ease;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ &:active {
+ color: var(--accentDarken);
+ transition: color 0s ease;
+ }
+ }
+
+ .files {
+ display: block;
+ margin: 0;
+ padding: 0 8px;
+ list-style: none;
+
+ &:after {
+ content: '';
+ display: block;
+ clear: both;
+ }
+
+ > li {
+ display: block;
+ float: left;
+ margin: 4px;
+ padding: 0;
+ width: 64px;
+ height: 64px;
+ background-color: #eee;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: cover;
+ cursor: move;
+
+ &:hover {
+ > .remove {
+ display: block;
+ }
+ }
+
+ > .remove {
+ display: none;
+ position: absolute;
+ right: -6px;
+ top: -6px;
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ outline: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ cursor: pointer;
+ }
+ }
+ }
+
+ .attach-from-local,
+ .attach-from-drive {
+ margin: 0;
+ padding: 16px;
+ font-size: 1em;
+ font-weight: normal;
+ text-decoration: none;
+ color: #aaa;
+ transition: color 0.1s ease;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ &:active {
+ color: var(--accentDarken);
+ transition: color 0s ease;
+ }
+ }
+
+ input[type=file] {
+ display: none;
+ }
+}
+</style>
diff --git a/src/client/pages/messaging-room.message.vue b/src/client/pages/messaging-room.message.vue
new file mode 100644
index 0000000000..392eb6acb0
--- /dev/null
+++ b/src/client/pages/messaging-room.message.vue
@@ -0,0 +1,336 @@
+<template>
+<div class="thvuemwp" :data-is-me="isMe">
+ <mk-avatar class="avatar" :user="message.user"/>
+ <div class="content">
+ <div class="balloon _panel" :data-no-text="message.text == null">
+ <button class="delete-button" v-if="isMe" :title="$t('@.delete')" @click="del">
+ <img src="/assets/desktop/remove.png" alt="Delete"/>
+ </button>
+ <div class="content" v-if="!message.isDeleted">
+ <mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/>
+ <div class="file" v-if="message.file">
+ <a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
+ <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"
+ :style="{ backgroundColor: message.file.properties.avgColor || 'transparent' }"/>
+ <p v-else>{{ message.file.name }}</p>
+ </a>
+ </div>
+ </div>
+ <div class="content" v-else>
+ <p class="is-deleted">{{ $t('deleted') }}</p>
+ </div>
+ </div>
+ <div></div>
+ <mk-url-preview v-for="url in urls" :url="url" :key="url"/>
+ <footer>
+ <template v-if="isGroup">
+ <span class="read" v-if="message.reads.length > 0">{{ $t('messageRead') }} {{ message.reads.length }}</span>
+ </template>
+ <template v-else>
+ <span class="read" v-if="isMe && message.isRead">{{ $t('messageRead') }}</span>
+ </template>
+ <mk-time :time="message.createdAt"/>
+ <template v-if="message.is_edited"><fa icon="pencil-alt"/></template>
+ </footer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../i18n';
+import { parse } from '../../mfm/parse';
+import { unique } from '../../prelude/array';
+
+export default Vue.extend({
+ i18n,
+ props: {
+ message: {
+ required: true
+ },
+ isGroup: {
+ required: false
+ }
+ },
+ computed: {
+ isMe(): boolean {
+ return this.message.userId == this.$store.state.i.id;
+ },
+ urls(): string[] {
+ if (this.message.text) {
+ const ast = parse(this.message.text);
+ return unique(ast
+ .filter(t => ((t.node.type == 'url' || t.node.type == 'link') && t.node.props.url && !t.node.props.silent))
+ .map(t => t.node.props.url));
+ } else {
+ return null;
+ }
+ }
+ },
+ methods: {
+ del() {
+ this.$root.api('messaging/messages/delete', {
+ messageId: this.message.id
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.thvuemwp {
+ $me-balloon-color: var(--accent);
+
+ position: relative;
+ background-color: transparent;
+ display: flex;
+
+ > .avatar {
+ display: block;
+ width: 54px;
+ height: 54px;
+ transition: all 0.1s ease;
+
+ @media (max-width: 400px) {
+ width: 48px;
+ height: 48px;
+ }
+ }
+
+ > .content {
+ min-width: 0;
+
+ > .balloon {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ padding: 0;
+ min-height: 38px;
+ border-radius: 16px;
+ max-width: 100%;
+
+ &:before {
+ content: "";
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ top: 12px;
+ }
+
+ & + * {
+ clear: both;
+ }
+
+ &:hover {
+ > .delete-button {
+ display: block;
+ }
+ }
+
+ > .delete-button {
+ display: none;
+ position: absolute;
+ z-index: 1;
+ top: -4px;
+ right: -4px;
+ margin: 0;
+ padding: 0;
+ cursor: pointer;
+ outline: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+
+ > img {
+ vertical-align: bottom;
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ }
+ }
+
+ > .content {
+ max-width: 100%;
+
+ > .is-deleted {
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ font-size: 1em;
+ color: rgba(#000, 0.5);
+ }
+
+ > .text {
+ display: block;
+ margin: 0;
+ padding: 12px 18px;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ font-size: 1em;
+ color: rgba(#000, 0.8);
+
+ @media (max-width: 500px) {
+ padding: 8px 16px;
+ }
+
+ @media (max-width: 400px) {
+ font-size: 0.9em;
+ }
+
+ & + .file {
+ > a {
+ border-radius: 0 0 16px 16px;
+ }
+ }
+ }
+
+ > .file {
+ > a {
+ display: block;
+ max-width: 100%;
+ border-radius: 16px;
+ overflow: hidden;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: none;
+
+ > p {
+ background: #ccc;
+ }
+ }
+
+ > * {
+ display: block;
+ margin: 0;
+ width: 100%;
+ max-height: 512px;
+ object-fit: contain;
+ }
+
+ > p {
+ padding: 30px;
+ text-align: center;
+ color: #555;
+ background: #ddd;
+ }
+ }
+ }
+ }
+ }
+
+ > .mk-url-preview {
+ margin: 8px 0;
+ }
+
+ > footer {
+ display: block;
+ margin: 2px 0 0 0;
+ font-size: 10px;
+ color: var(--messagingRoomMessageInfo);
+
+ > .read {
+ margin: 0 8px;
+ }
+
+ > [data-icon] {
+ margin-left: 4px;
+ }
+ }
+ }
+
+ &:not([data-is-me]) {
+
+ > .content {
+ padding-left: 16px;
+ padding-right: 32px;
+
+ > .balloon {
+ $color: var(--panel);
+ background: $color;
+
+ &[data-no-text] {
+ background: transparent;
+ }
+
+ &:not([data-no-text]):before {
+ left: -14px;
+ border-top: solid 8px transparent;
+ border-right: solid 8px $color;
+ border-bottom: solid 8px transparent;
+ border-left: solid 8px transparent;
+ }
+
+ > .content {
+ > .text {
+ color: var(--fg);
+ }
+ }
+ }
+
+ > footer {
+ text-align: left;
+ }
+ }
+ }
+
+ &[data-is-me] {
+ flex-direction: row-reverse;
+
+ > .content {
+ padding-right: 16px;
+ padding-left: 32px;
+ text-align: right;
+
+ > .balloon {
+ background: $me-balloon-color;
+ text-align: left;
+
+ &[data-no-text] {
+ background: transparent;
+ }
+
+ &:not([data-no-text]):before {
+ right: -14px;
+ left: auto;
+ border-top: solid 8px transparent;
+ border-right: solid 8px transparent;
+ border-bottom: solid 8px transparent;
+ border-left: solid 8px $me-balloon-color;
+ }
+
+ > .content {
+
+ > p.is-deleted {
+ color: rgba(#fff, 0.5);
+ }
+
+ > .text {
+ &, * {
+ color: #fff !important;
+ }
+ }
+ }
+ }
+
+ > footer {
+ text-align: right;
+
+ > .read {
+ user-select: none;
+ }
+ }
+ }
+ }
+
+ &[data-is-deleted] {
+ > .balloon {
+ opacity: 0.5;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/messaging-room.vue b/src/client/pages/messaging-room.vue
new file mode 100644
index 0000000000..cba84b6de7
--- /dev/null
+++ b/src/client/pages/messaging-room.vue
@@ -0,0 +1,395 @@
+<template>
+<div class="mk-messaging-room"
+ @dragover.prevent.stop="onDragover"
+ @drop.prevent.stop="onDrop"
+>
+ <template v-if="!fetching && user">
+ <portal to="title"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
+ <portal to="avatar"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
+ </template>
+ <template v-if="!fetching && group">
+ <portal to="title">{{ group.name }}</portal>
+ </template>
+
+ <div class="body">
+ <mk-loading v-if="fetching"/>
+ <p class="empty" v-if="!fetching && messages.length == 0"><fa icon="info-circle"/>{{ user ? $t('not-talked-user') : $t('not-talked-group') }}</p>
+ <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><fa :icon="faFlag"/>{{ $t('noMoreHistory') }}</p>
+ <button class="more _button" :class="{ fetching: fetchingMoreMessages }" v-if="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
+ <template v-if="fetchingMoreMessages"><fa icon="spinner" pulse fixed-width/></template>{{ fetchingMoreMessages ? $t('@.loading') : $t('@.load-more') }}
+ </button>
+ <x-list class="messages" :items="messages" v-slot="{ item: message, i }" direction="up">
+ <x-message :message="message" :is-group="group != null" :key="message.id" :data-index="messages.length - i"/>
+ </x-list>
+ </div>
+ <footer>
+ <transition name="fade">
+ <div class="new-message" v-show="showIndicator">
+ <button class="_buttonPrimary" @click="onIndicatorClick"><i><fa :icon="faArrowCircleDown"/></i>{{ $t('new-message') }}</button>
+ </div>
+ </transition>
+ <x-form v-if="!fetching" :user="user" :group="group" ref="form"/>
+ </footer>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faArrowCircleDown, faFlag } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../i18n';
+import XList from '../components/date-separated-list.vue';
+import XMessage from './messaging-room.message.vue';
+import XForm from './messaging-room.form.vue';
+import { url } from '../config';
+import parseAcct from '../../misc/acct/parse';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XMessage,
+ XForm,
+ XList,
+ },
+
+ data() {
+ return {
+ fetching: true,
+ user: null,
+ group: null,
+ fetchingMoreMessages: false,
+ messages: [],
+ existMoreMessages: false,
+ connection: null,
+ showIndicator: false,
+ timer: null,
+ faArrowCircleDown, faFlag
+ };
+ },
+
+ computed: {
+ form(): any {
+ return this.$refs.form;
+ }
+ },
+
+ watch: {
+ $route: 'fetch'
+ },
+
+ mounted() {
+ this.fetch();
+ },
+
+ beforeDestroy() {
+ this.connection.dispose();
+
+ window.removeEventListener('scroll', this.onScroll);
+
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
+ },
+
+ methods: {
+ async fetch() {
+ this.fetching = true;
+ if (this.$route.params.user) {
+ const user = await this.$root.api('users/show', parseAcct(this.$route.params.user));
+ this.user = user;
+ } else {
+ const group = await this.$root.api('users/groups/show', { groupId: this.$route.params.group });
+ this.group = group;
+ }
+
+ this.connection = this.$root.stream.connectToChannel('messaging', {
+ otherparty: this.user ? this.user.id : undefined,
+ group: this.group ? this.group.id : undefined,
+ });
+
+ this.connection.on('message', this.onMessage);
+ this.connection.on('read', this.onRead);
+ this.connection.on('deleted', this.onDeleted);
+
+ window.addEventListener('scroll', this.onScroll, { passive: true });
+
+ document.addEventListener('visibilitychange', this.onVisibilitychange);
+
+ this.fetchMessages().then(() => {
+ this.fetching = false;
+ this.scrollToBottom();
+ });
+ },
+
+ onDragover(e) {
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file';
+
+ if (isFile || isDriveFile) {
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ } else {
+ e.dataTransfer.dropEffect = 'none';
+ }
+ },
+
+ onDrop(e): void {
+ // ファイルだったら
+ if (e.dataTransfer.files.length == 1) {
+ this.form.upload(e.dataTransfer.files[0]);
+ return;
+ } else if (e.dataTransfer.files.length > 1) {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('only-one-file-attached')
+ });
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData('mk_drive_file');
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.form.file = file;
+ }
+ //#endregion
+ },
+
+ fetchMessages() {
+ return new Promise((resolve, reject) => {
+ const max = this.existMoreMessages ? 20 : 10;
+
+ this.$root.api('messaging/messages', {
+ userId: this.user ? this.user.id : undefined,
+ groupId: this.group ? this.group.id : undefined,
+ limit: max + 1,
+ untilId: this.existMoreMessages ? this.messages[0].id : undefined
+ }).then(messages => {
+ if (messages.length == max + 1) {
+ this.existMoreMessages = true;
+ messages.pop();
+ } else {
+ this.existMoreMessages = false;
+ }
+
+ this.messages.unshift.apply(this.messages, messages.reverse());
+ resolve();
+ });
+ });
+ },
+
+ fetchMoreMessages() {
+ this.fetchingMoreMessages = true;
+ this.fetchMessages().then(() => {
+ this.fetchingMoreMessages = false;
+ });
+ },
+
+ onMessage(message) {
+ // サウンドを再生する
+ if (this.$store.state.device.enableSounds) {
+ const sound = new Audio(`${url}/assets/message.mp3`);
+ sound.volume = this.$store.state.device.soundVolume;
+ sound.play();
+ }
+
+ const isBottom = this.isBottom();
+
+ this.messages.push(message);
+ if (message.userId != this.$store.state.i.id && !document.hidden) {
+ this.connection.send('read', {
+ id: message.id
+ });
+ }
+
+ if (isBottom) {
+ // Scroll to bottom
+ this.$nextTick(() => {
+ this.scrollToBottom();
+ });
+ } else if (message.userId != this.$store.state.i.id) {
+ // Notify
+ this.notifyNewMessage();
+ }
+ },
+
+ onRead(x) {
+ if (this.user) {
+ if (!Array.isArray(x)) x = [x];
+ for (const id of x) {
+ if (this.messages.some(x => x.id == id)) {
+ const exist = this.messages.map(x => x.id).indexOf(id);
+ this.messages[exist].isRead = true;
+ }
+ }
+ } else if (this.group) {
+ for (const id of x.ids) {
+ if (this.messages.some(x => x.id == id)) {
+ const exist = this.messages.map(x => x.id).indexOf(id);
+ this.messages[exist].reads.push(x.userId);
+ }
+ }
+ }
+ },
+
+ onDeleted(id) {
+ const msg = this.messages.find(m => m.id === id);
+ if (msg) {
+ this.messages = this.messages.filter(m => m.id !== msg.id);
+ }
+ },
+
+ isBottom() {
+ const asobi = 64;
+ const current = this.isNaked
+ ? window.scrollY + window.innerHeight
+ : this.$el.scrollTop + this.$el.offsetHeight;
+ const max = this.isNaked
+ ? document.body.offsetHeight
+ : this.$el.scrollHeight;
+ return current > (max - asobi);
+ },
+
+ scrollToBottom() {
+ window.scroll(0, document.body.offsetHeight);
+ },
+
+ onIndicatorClick() {
+ this.showIndicator = false;
+ this.scrollToBottom();
+ },
+
+ notifyNewMessage() {
+ this.showIndicator = true;
+
+ if (this.timer) clearTimeout(this.timer);
+
+ this.timer = setTimeout(() => {
+ this.showIndicator = false;
+ }, 4000);
+ },
+
+ onScroll() {
+ const el = this.isNaked ? window.document.documentElement : this.$el;
+ const current = el.scrollTop + el.clientHeight;
+ if (current > el.scrollHeight - 1) {
+ this.showIndicator = false;
+ }
+ },
+
+ onVisibilitychange() {
+ if (document.hidden) return;
+ for (const message of this.messages) {
+ if (message.userId !== this.$store.state.i.id && !message.isRead) {
+ this.connection.send('read', {
+ id: message.id
+ });
+ }
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-messaging-room {
+
+ > .body {
+ width: 100%;
+
+ > .empty {
+ width: 100%;
+ margin: 0;
+ padding: 16px 8px 8px 8px;
+ text-align: center;
+ font-size: 0.8em;
+ opacity: 0.5;
+
+ [data-icon] {
+ margin-right: 4px;
+ }
+ }
+
+ > .no-history {
+ display: block;
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+ font-size: 0.8em;
+ color: var(--messagingRoomInfo);
+ opacity: 0.5;
+
+ [data-icon] {
+ margin-right: 4px;
+ }
+ }
+
+ > .more {
+ display: block;
+ margin: 16px auto;
+ padding: 0 12px;
+ line-height: 24px;
+ color: #fff;
+ background: rgba(#000, 0.3);
+ border-radius: 12px;
+
+ &:hover {
+ background: rgba(#000, 0.4);
+ }
+
+ &:active {
+ background: rgba(#000, 0.5);
+ }
+
+ &.fetching {
+ cursor: wait;
+ }
+
+ > [data-icon] {
+ margin-right: 4px;
+ }
+ }
+
+ > .messages {
+ > ::v-deep * {
+ margin-bottom: 16px;
+ }
+ }
+ }
+
+ > footer {
+ width: 100%;
+
+ > .new-message {
+ position: absolute;
+ top: -48px;
+ width: 100%;
+ padding: 8px 0;
+ text-align: center;
+
+ > button {
+ display: inline-block;
+ margin: 0;
+ padding: 0 12px 0 30px;
+ line-height: 32px;
+ font-size: 12px;
+ border-radius: 16px;
+
+ > i {
+ position: absolute;
+ top: 0;
+ left: 10px;
+ line-height: 32px;
+ font-size: 16px;
+ }
+ }
+ }
+ }
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity 0.1s;
+}
+
+.fade-enter, .fade-leave-to {
+ transition: opacity 0.5s;
+ opacity: 0;
+}
+</style>
diff --git a/src/client/pages/messaging.vue b/src/client/pages/messaging.vue
new file mode 100644
index 0000000000..b94e01cad9
--- /dev/null
+++ b/src/client/pages/messaging.vue
@@ -0,0 +1,328 @@
+<template>
+<div class="mk-messaging">
+ <portal to="icon"><fa :icon="faComments"/></portal>
+ <portal to="title">{{ $t('messaging') }}</portal>
+
+ <mk-button @click="start" primary class="start"><fa :icon="faPlus"/> {{ $t('startMessaging') }}</mk-button>
+
+ <sequential-entrance class="history" v-if="messages.length > 0" :delay="30">
+ <router-link v-for="(message, i) in messages"
+ class="message _panel"
+ :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
+ :data-is-me="isMe(message)"
+ :data-is-read="message.groupId ? message.reads.includes($store.state.i.id) : message.isRead"
+ :data-index="i"
+ :key="message.id"
+ >
+ <div>
+ <mk-avatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user"/>
+ <header v-if="message.groupId">
+ <span class="name">{{ message.group.name }}</span>
+ <mk-time :time="message.createdAt"/>
+ </header>
+ <header v-else>
+ <span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span>
+ <span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span>
+ <mk-time :time="message.createdAt"/>
+ </header>
+ <div class="body">
+ <p class="text"><span class="me" v-if="isMe(message)">{{ $t('you') }}:</span>{{ message.text }}</p>
+ </div>
+ </div>
+ </router-link>
+ </sequential-entrance>
+ <p class="no-history" v-if="!fetching && messages.length == 0">{{ $t('no-history') }}</p>
+ <p class="fetching" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('@.loading') }}<mk-ellipsis/></p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faUser, faUsers, faComments, faPlus } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../i18n';
+import getAcct from '../../misc/acct/render';
+import MkButton from '../components/ui/button.vue';
+import MkUserSelect from '../components/user-select.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ fetching: true,
+ moreFetching: false,
+ messages: [],
+ connection: null,
+ faUser, faUsers, faComments, faPlus
+ };
+ },
+
+ mounted() {
+ this.connection = this.$root.stream.useSharedConnection('messagingIndex');
+
+ this.connection.on('message', this.onMessage);
+ this.connection.on('read', this.onRead);
+
+ this.$root.api('messaging/history', { group: false }).then(userMessages => {
+ this.$root.api('messaging/history', { group: true }).then(groupMessages => {
+ const messages = userMessages.concat(groupMessages);
+ messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ this.messages = messages;
+ this.fetching = false;
+ });
+ });
+ },
+
+ beforeDestroy() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ getAcct,
+
+ isMe(message) {
+ return message.userId == this.$store.state.i.id;
+ },
+
+ onMessage(message) {
+ if (message.recipientId) {
+ this.messages = this.messages.filter(m => !(
+ (m.recipientId == message.recipientId && m.userId == message.userId) ||
+ (m.recipientId == message.userId && m.userId == message.recipientId)));
+
+ this.messages.unshift(message);
+ } else if (message.groupId) {
+ this.messages = this.messages.filter(m => m.groupId !== message.groupId);
+ this.messages.unshift(message);
+ }
+ },
+
+ onRead(ids) {
+ for (const id of ids) {
+ const found = this.messages.find(m => m.id == id);
+ if (found) {
+ if (found.recipientId) {
+ found.isRead = true;
+ } else if (found.groupId) {
+ found.reads.push(this.$store.state.i.id);
+ }
+ }
+ }
+ },
+
+ start(ev) {
+ this.$root.menu({
+ items: [{
+ text: this.$t('withUser'),
+ action: () => { this.startUser() }
+ }, {
+ text: this.$t('withGroup'),
+ action: () => { this.startGroup() }
+ }],
+ noCenter: true,
+ source: ev.currentTarget || ev.target,
+ });
+ },
+
+ async startUser() {
+ this.$root.new(MkUserSelect, {}).$once('selected', user => {
+ this.$router.push(`/my/messaging/${getAcct(user)}`);
+ });
+ },
+
+ async startGroup() {
+ const groups1 = await this.$root.api('users/groups/owned');
+ const groups2 = await this.$root.api('users/groups/joined');
+ const { canceled, result: group } = await this.$root.dialog({
+ type: null,
+ title: this.$t('select-group'),
+ select: {
+ items: groups1.concat(groups2).map(group => ({
+ value: group, text: group.name
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.navigateGroup(group);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-messaging {
+
+ > .start {
+ margin: 0 auto 16px auto;
+ }
+
+ > .history {
+ > .message {
+ display: block;
+ text-decoration: none;
+ margin-bottom: 16px;
+
+ @media (max-width: 500px) {
+ margin-bottom: 8px;
+ }
+
+ * {
+ pointer-events: none;
+ user-select: none;
+ }
+
+ &:hover {
+ .avatar {
+ filter: saturate(200%);
+ }
+ }
+
+ &:active {
+ }
+
+ &[data-is-read],
+ &[data-is-me] {
+ opacity: 0.8;
+ }
+
+ &:not([data-is-me]):not([data-is-read]) {
+ > div {
+ background-image: url("/assets/unread.svg");
+ background-repeat: no-repeat;
+ background-position: 0 center;
+ }
+ }
+
+ &:after {
+ content: "";
+ display: block;
+ clear: both;
+ }
+
+ > div {
+ padding: 20px 30px;
+
+ &:after {
+ content: "";
+ display: block;
+ clear: both;
+ }
+
+ > header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 1em;
+ font-weight: bold;
+ transition: all 0.1s ease;
+ }
+
+ > .username {
+ margin: 0 8px;
+ }
+
+ > .mk-time {
+ margin: 0 0 0 auto;
+ }
+ }
+
+ > .avatar {
+ float: left;
+ width: 54px;
+ height: 54px;
+ margin: 0 16px 0 0;
+ border-radius: 8px;
+ transition: all 0.1s ease;
+ }
+
+ > .body {
+
+ > .text {
+ display: block;
+ margin: 0 0 0 0;
+ padding: 0;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ font-size: 1.1em;
+ color: var(--faceText);
+
+ .me {
+ opacity: 0.7;
+ }
+ }
+
+ > .image {
+ display: block;
+ max-width: 100%;
+ max-height: 512px;
+ }
+ }
+ }
+ }
+ }
+
+ > .no-history {
+ margin: 0;
+ padding: 2em 1em;
+ text-align: center;
+ color: #999;
+ font-weight: 500;
+ }
+
+ > .fetching {
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+ color: var(--text);
+
+ > [data-icon] {
+ margin-right: 4px;
+ }
+ }
+
+ @media (max-width: 400px) {
+ > .search {
+ > .result {
+ > .users {
+ > li {
+ padding: 8px 16px;
+ }
+ }
+ }
+ }
+
+ > .history {
+ > .message {
+ &:not([data-is-me]):not([data-is-read]) {
+ > div {
+ background-image: none;
+ border-left: solid 4px #3aa2dc;
+ }
+ }
+
+ > div {
+ padding: 16px;
+ font-size: 14px;
+
+ > .avatar {
+ margin: 0 12px 0 0;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/my-antennas/index.antenna.vue b/src/client/pages/my-antennas/index.antenna.vue
new file mode 100644
index 0000000000..a4b140db1e
--- /dev/null
+++ b/src/client/pages/my-antennas/index.antenna.vue
@@ -0,0 +1,174 @@
+<template>
+<div class="shaynizk _section">
+ <div class="_title" v-if="antenna.name">{{ antenna.name }}</div>
+ <div class="_content body">
+ <mk-input v-model="name" style="margin-top: 8px;">
+ <span>{{ $t('name') }}</span>
+ </mk-input>
+ <mk-select v-model="src">
+ <template #label>{{ $t('antennaSource') }}</template>
+ <option value="all">{{ $t('_antennaSources.all') }}</option>
+ <option value="home">{{ $t('_antennaSources.homeTimeline') }}</option>
+ <option value="users">{{ $t('_antennaSources.users') }}</option>
+ <option value="list">{{ $t('_antennaSources.userList') }}</option>
+ </mk-select>
+ <mk-select v-model="userListId" v-if="src === 'list'">
+ <template #label>{{ $t('userList') }}</template>
+ <option v-for="list in userLists" :value="list.id" :key="list.id">{{ list.name }}</option>
+ </mk-select>
+ <mk-textarea v-model="users" v-if="src === 'users'">
+ <span>{{ $t('users') }}</span>
+ <template #desc>{{ $t('antennaUsersDescription') }} <button class="_textButton" @click="addUser">{{ $t('addUser') }}</button></template>
+ </mk-textarea>
+ <mk-switch v-model="withReplies">{{ $t('withReplies') }}</mk-switch>
+ <mk-textarea v-model="keywords">
+ <span>{{ $t('antennaKeywords') }}</span>
+ <template #desc>{{ $t('antennaKeywordsDescription') }}</template>
+ </mk-textarea>
+ <mk-switch v-model="caseSensitive">{{ $t('caseSensitive') }}</mk-switch>
+ <mk-switch v-model="withFile">{{ $t('withFileAntenna') }}</mk-switch>
+ <mk-switch v-model="notify">{{ $t('notifyAntenna') }}</mk-switch>
+ </div>
+ <div class="_footer">
+ <mk-button inline @click="saveAntenna()" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ <mk-button inline @click="deleteAntenna()" v-if="antenna.id != null"><fa :icon="faTrash"/> {{ $t('delete') }}</mk-button>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSave, faTrash } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../i18n';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkSelect from '../../components/ui/select.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import MkUserSelect from '../../components/user-select.vue';
+import getAcct from '../../../misc/acct/render';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton, MkInput, MkTextarea, MkSelect, MkSwitch
+ },
+
+ props: {
+ antenna: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ name: '',
+ src: '',
+ userListId: null,
+ users: '',
+ keywords: '',
+ caseSensitive: false,
+ withReplies: false,
+ withFile: false,
+ notify: false,
+ userLists: null,
+ faSave, faTrash
+ };
+ },
+
+ watch: {
+ async src() {
+ if (this.src === 'list' && this.userLists === null) {
+ this.userLists = await this.$root.api('users/lists/list');
+ }
+ }
+ },
+
+ created() {
+ this.name = this.antenna.name;
+ this.src = this.antenna.src;
+ this.userListId = this.antenna.userListId;
+ this.users = this.antenna.users.join('\n');
+ this.keywords = this.antenna.keywords.map(x => x.join(' ')).join('\n');
+ this.caseSensitive = this.antenna.caseSensitive;
+ this.withReplies = this.antenna.withReplies;
+ this.withFile = this.antenna.withFile;
+ this.notify = this.antenna.notify;
+ },
+
+ methods: {
+ async saveAntenna() {
+ if (this.antenna.id == null) {
+ await this.$root.api('antennas/create', {
+ name: this.name,
+ src: this.src,
+ userListId: this.userListId,
+ withReplies: this.withReplies,
+ withFile: this.withFile,
+ notify: this.notify,
+ caseSensitive: this.caseSensitive,
+ users: this.users.trim().split('\n').map(x => x.trim()),
+ keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' '))
+ });
+ this.$emit('created');
+ } else {
+ await this.$root.api('antennas/update', {
+ antennaId: this.antenna.id,
+ name: this.name,
+ src: this.src,
+ userListId: this.userListId,
+ withReplies: this.withReplies,
+ withFile: this.withFile,
+ notify: this.notify,
+ caseSensitive: this.caseSensitive,
+ users: this.users.trim().split('\n').map(x => x.trim()),
+ keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' '))
+ });
+ }
+
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ },
+
+ async deleteAntenna() {
+ const { canceled } = await this.$root.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.antenna.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await this.$root.api('antennas/delete', {
+ antennaId: this.antenna.id,
+ });
+
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ this.$emit('deleted');
+ },
+
+ addUser() {
+ this.$root.new(MkUserSelect, {}).$once('selected', user => {
+ this.users = this.users.trim();
+ this.users += '\n@' + getAcct(user);
+ this.users = this.users.trim();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.shaynizk {
+ > .body {
+ max-height: 250px;
+ overflow: auto;
+ }
+}
+</style>
diff --git a/src/client/pages/my-antennas/index.vue b/src/client/pages/my-antennas/index.vue
new file mode 100644
index 0000000000..3a9a11b541
--- /dev/null
+++ b/src/client/pages/my-antennas/index.vue
@@ -0,0 +1,80 @@
+<template>
+<div class="ieepwinx">
+ <portal to="icon"><fa :icon="faSatellite"/></portal>
+ <portal to="title">{{ $t('manageAntennas') }}</portal>
+
+ <mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('createAntenna') }}</mk-button>
+
+ <x-antenna v-if="draft" :antenna="draft" @created="onAntennaCreated" style="margin-bottom: var(--margin);"/>
+
+ <mk-pagination :pagination="pagination" #default="{items}" class="antennas" ref="list">
+ <x-antenna v-for="(antenna, i) in items" :key="antenna.id" :data-index="i" :antenna="antenna" @created="onAntennaDeleted"/>
+ </mk-pagination>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSatellite, faPlus } from '@fortawesome/free-solid-svg-icons';
+import MkPagination from '../../components/ui/pagination.vue';
+import MkButton from '../../components/ui/button.vue';
+import XAntenna from './index.antenna.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('manageAntennas') as string,
+ };
+ },
+
+ components: {
+ MkPagination,
+ MkButton,
+ XAntenna,
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'antennas/list',
+ limit: 10,
+ },
+ draft: null,
+ faSatellite, faPlus
+ };
+ },
+
+ methods: {
+ create() {
+ this.draft = {
+ name: '',
+ src: 'all',
+ userListId: null,
+ users: [],
+ keywords: [],
+ withReplies: false,
+ caseSensitive: false,
+ withFile: false,
+ notify: false
+ };
+ },
+
+ onAntennaCreated() {
+ this.$refs.list.reload();
+ this.draft = null;
+ },
+
+ onAntennaDeleted() {
+ this.$refs.list.reload();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ieepwinx {
+ > .add {
+ margin: 0 auto 16px auto;
+ }
+}
+</style>
diff --git a/src/client/pages/my-lists/index.vue b/src/client/pages/my-lists/index.vue
new file mode 100644
index 0000000000..6c4b46e85c
--- /dev/null
+++ b/src/client/pages/my-lists/index.vue
@@ -0,0 +1,75 @@
+<template>
+<div class="qkcjvfiv">
+ <portal to="icon"><fa :icon="faListUl"/></portal>
+ <portal to="title">{{ $t('manageLists') }}</portal>
+
+ <mk-button @click="create" primary class="add"><fa :icon="faPlus"/> {{ $t('createList') }}</mk-button>
+
+ <mk-pagination :pagination="pagination" #default="{items}" class="lists" ref="list">
+ <div class="list _panel" v-for="(list, i) in items" :key="list.id" :data-index="i">
+ <router-link :to="`/lists/${ list.id }`">{{ list.name }}</router-link>
+ </div>
+ </mk-pagination>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faListUl, faPlus } from '@fortawesome/free-solid-svg-icons';
+import MkPagination from '../../components/ui/pagination.vue';
+import MkButton from '../../components/ui/button.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('manageLists') as string,
+ };
+ },
+
+ components: {
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/lists/list',
+ limit: 10,
+ },
+ faListUl, faPlus
+ };
+ },
+
+ methods: {
+ async create() {
+ const { canceled, result: name } = await this.$root.dialog({
+ title: this.$t('enterListName'),
+ input: true
+ });
+ if (canceled) return;
+ await this.$root.api('users/lists/create', { name: name });
+ this.$refs.list.reload();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qkcjvfiv {
+ > .add {
+ margin: 0 auto 16px auto;
+ }
+
+ > .lists {
+ > .list {
+ display: flex;
+ padding: 16px;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/my-lists/list.vue b/src/client/pages/my-lists/list.vue
new file mode 100644
index 0000000000..8899b4c44d
--- /dev/null
+++ b/src/client/pages/my-lists/list.vue
@@ -0,0 +1,163 @@
+<template>
+<div class="mk-list-page">
+ <transition name="zoom" mode="out-in">
+ <div v-if="list" :key="list.id" class="_section list">
+ <div class="_title">{{ list.name }}</div>
+ <div class="_content">
+ <div class="users">
+ <div class="user" v-for="(user, i) in users" :key="user.id" :data-index="i">
+ <mk-avatar :user="user" class="avatar"/>
+ <div class="body">
+ <mk-user-name :user="user" class="name"/>
+ <mk-acct :user="user" class="acct"/>
+ </div>
+ <div class="action">
+ <button class="_button" @click="removeUser(user)"><fa :icon="faTimes"/></button>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="_footer">
+ <mk-button inline @click="renameList()">{{ $t('renameList') }}</mk-button>
+ <mk-button inline @click="deleteList()">{{ $t('deleteList') }}</mk-button>
+ </div>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faTimes } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../i18n';
+import Progress from '../../scripts/loading';
+import MkButton from '../../components/ui/button.vue';
+
+export default Vue.extend({
+ i18n,
+
+ metaInfo() {
+ return {
+ title: this.list ? `${this.list.name} | ${this.$t('manageLists')}` : this.$t('manageLists')
+ };
+ },
+
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ list: null,
+ users: [],
+ faTimes
+ };
+ },
+
+ watch: {
+ $route: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ Progress.start();
+ this.$root.api('users/lists/show', {
+ listId: this.$route.params.list
+ }).then(list => {
+ this.list = list;
+ this.$root.api('users/show', {
+ userIds: this.list.userIds
+ }).then(users => {
+ this.users = users;
+ Progress.done();
+ });
+ });
+ },
+
+ removeUser(user) {
+ this.$root.api('users/lists/pull', {
+ listId: this.list.id,
+ userId: user.id
+ }).then(() => {
+ this.users = this.users.filter(x => x.id !== user.id);
+ });
+ },
+
+ async renameList() {
+ const { canceled, result: name } = await this.$root.dialog({
+ title: this.$t('enterListName'),
+ input: {
+ default: this.list.name
+ }
+ });
+ if (canceled) return;
+
+ await this.$root.api('users/lists/update', {
+ listId: this.list.id,
+ name: name
+ });
+
+ this.list.name = name;
+ },
+
+ async deleteList() {
+ const { canceled } = await this.$root.dialog({
+ type: 'warning',
+ text: this.$t('deleteListConfirm', { list: this.list.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await this.$root.api('users/lists/delete', {
+ listId: this.list.id
+ });
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ this.$router.push('/my/lists');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-list-page {
+ > .list {
+ > ._content {
+ max-height: 400px;
+ overflow: auto;
+
+ > .users {
+ > .user {
+ display: flex;
+ align-items: center;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ flex: 1;
+ padding: 8px;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue
new file mode 100644
index 0000000000..e7cdf19f81
--- /dev/null
+++ b/src/client/pages/note.vue
@@ -0,0 +1,55 @@
+<template>
+<div class="mk-note-page">
+ <transition name="zoom" mode="out-in">
+ <x-note v-if="note" :note="note" :key="note.id" :detail="true"/>
+ <div v-else-if="error">
+ <mk-error @retry="fetch()"/>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../i18n';
+import Progress from '../scripts/loading';
+import XNote from '../components/note.vue';
+
+export default Vue.extend({
+ i18n,
+ metaInfo() {
+ return {
+ title: this.$t('note') as string
+ };
+ },
+ components: {
+ XNote
+ },
+ data() {
+ return {
+ note: null,
+ error: null,
+ };
+ },
+ watch: {
+ $route: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ Progress.start();
+ this.$root.api('notes/show', {
+ noteId: this.$route.params.note
+ }).then(note => {
+ this.note = note;
+ }).catch(e => {
+ this.error = e;
+ }).finally(() => {
+ Progress.done();
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/page-editor/els/page-editor.el.button.vue b/src/client/pages/page-editor/els/page-editor.el.button.vue
new file mode 100644
index 0000000000..8e74124b79
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.button.vue
@@ -0,0 +1,83 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.button') }}</template>
+
+ <section class="xfhsjczc">
+ <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._button.text') }}</span></mk-input>
+ <mk-switch v-model="value.primary"><span>{{ $t('_pages.blocks._button.colored') }}</span></mk-switch>
+ <mk-select v-model="value.action">
+ <template #label>{{ $t('_pages.blocks._button.action') }}</template>
+ <option value="dialog">{{ $t('_pages.blocks._button._action.dialog') }}</option>
+ <option value="resetRandom">{{ $t('_pages.blocks._button._action.resetRandom') }}</option>
+ <option value="pushEvent">{{ $t('_pages.blocks._button._action.pushEvent') }}</option>
+ </mk-select>
+ <template v-if="value.action === 'dialog'">
+ <mk-input v-model="value.content"><span>{{ $t('_pages.blocks._button._action._dialog.content') }}</span></mk-input>
+ </template>
+ <template v-else-if="value.action === 'pushEvent'">
+ <mk-input v-model="value.event"><span>{{ $t('_pages.blocks._button._action._pushEvent.event') }}</span></mk-input>
+ <mk-input v-model="value.message"><span>{{ $t('_pages.blocks._button._action._pushEvent.message') }}</span></mk-input>
+ <mk-select v-model="value.var">
+ <template #label>{{ $t('_pages.blocks._button._action._pushEvent.variable') }}</template>
+ <option :value="null">{{ $t('_pages.blocks._button._action._pushEvent.no-variable') }}</option>
+ <option v-for="v in aiScript.getVarsByType()" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$t('_pages.script.pageVariables')">
+ <option v-for="v in aiScript.getPageVarsByType()" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$t('_pages.script.enviromentVariables')">
+ <option v-for="v in aiScript.getEnvVarsByType()" :value="v">{{ v }}</option>
+ </optgroup>
+ </mk-select>
+ </template>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBolt } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkSelect from '../../../components/ui/select.vue';
+import MkInput from '../../../components/ui/input.vue';
+import MkSwitch from '../../../components/ui/switch.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkSelect, MkInput, MkSwitch
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ aiScript: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ faBolt
+ };
+ },
+
+ created() {
+ if (this.value.text == null) Vue.set(this.value, 'text', '');
+ if (this.value.action == null) Vue.set(this.value, 'action', 'dialog');
+ if (this.value.content == null) Vue.set(this.value, 'content', null);
+ if (this.value.event == null) Vue.set(this.value, 'event', null);
+ if (this.value.message == null) Vue.set(this.value, 'message', null);
+ if (this.value.primary == null) Vue.set(this.value, 'primary', false);
+ if (this.value.var == null) Vue.set(this.value, 'var', null);
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.xfhsjczc {
+ padding: 0 16px 0 16px;
+}
+</style>
diff --git a/src/client/pages/page-editor/els/page-editor.el.counter.vue b/src/client/pages/page-editor/els/page-editor.el.counter.vue
new file mode 100644
index 0000000000..d9a4ddddee
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.counter.vue
@@ -0,0 +1,43 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.counter') }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._counter.name') }}</span></mk-input>
+ <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._counter.text') }}</span></mk-input>
+ <mk-input v-model="value.inc" type="number"><span>{{ $t('_pages.blocks._counter.inc') }}</span></mk-input>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '../../../components/ui/input.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faBolt, faMagic
+ };
+ },
+
+ created() {
+ if (this.value.name == null) Vue.set(this.value, 'name', '');
+ },
+});
+</script>
diff --git a/src/client/pages/page-editor/els/page-editor.el.if.vue b/src/client/pages/page-editor/els/page-editor.el.if.vue
new file mode 100644
index 0000000000..3c545a7ddc
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.if.vue
@@ -0,0 +1,91 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faQuestion"/> {{ $t('_pages.blocks.if') }}</template>
+ <template #func>
+ <button @click="add()">
+ <fa :icon="faPlus"/>
+ </button>
+ </template>
+
+ <section class="romcojzs">
+ <mk-select v-model="value.var">
+ <template #label>{{ $t('_pages.blocks._if.variable') }}</template>
+ <option v-for="v in aiScript.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$t('_pages.script.pageVariables')">
+ <option v-for="v in aiScript.getPageVarsByType('boolean')" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$t('_pages.script.enviromentVariables')">
+ <option v-for="v in aiScript.getEnvVarsByType('boolean')" :value="v">{{ v }}</option>
+ </optgroup>
+ </mk-select>
+
+ <x-blocks class="children" v-model="value.children" :ai-script="aiScript"/>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { v4 as uuid } from 'uuid';
+import { faPlus, faQuestion } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkSelect from '../../../components/ui/select.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkSelect
+ },
+
+ inject: ['getPageBlockList'],
+
+ props: {
+ value: {
+ required: true
+ },
+ aiScript: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ faPlus, faQuestion
+ };
+ },
+
+ beforeCreate() {
+ this.$options.components.XBlocks = require('../page-editor.blocks.vue').default
+ },
+
+ created() {
+ if (this.value.children == null) Vue.set(this.value, 'children', []);
+ if (this.value.var === undefined) Vue.set(this.value, 'var', null);
+ },
+
+ methods: {
+ async add() {
+ const { canceled, result: type } = await this.$root.dialog({
+ type: null,
+ title: this.$t('choose-block'),
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.value.children.push({ id, type });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.romcojzs {
+ padding: 0 16px 16px 16px;
+}
+</style>
diff --git a/src/client/pages/page-editor/els/page-editor.el.image.vue b/src/client/pages/page-editor/els/page-editor.el.image.vue
new file mode 100644
index 0000000000..e22701e5c0
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.image.vue
@@ -0,0 +1,78 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faImage"/> {{ $t('_pages.blocks.image') }}</template>
+ <template #func>
+ <button @click="choose()">
+ <fa :icon="faFolderOpen"/>
+ </button>
+ </template>
+
+ <section class="oyyftmcf">
+ <mk-file-thumbnail class="preview" v-if="file" :file="file" :detail="true" fit="contain" @click="choose()"/>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
+import { faImage, faFolderOpen } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkFileThumbnail from '../../../components/drive-file-thumbnail.vue';
+import { selectDriveFile } from '../../../scripts/select-drive-file';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkFileThumbnail
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ file: null,
+ faPencilAlt, faImage, faFolderOpen
+ };
+ },
+
+ created() {
+ if (this.value.fileId === undefined) Vue.set(this.value, 'fileId', null);
+ },
+
+ mounted() {
+ if (this.value.fileId == null) {
+ this.choose();
+ } else {
+ this.$root.api('drive/files/show', {
+ fileId: this.value.fileId
+ }).then(file => {
+ this.file = file;
+ });
+ }
+ },
+
+ methods: {
+ async choose() {
+ selectDriveFile(this.$root, false).then(file => {
+ this.file = file;
+ this.value.fileId = file.id;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.oyyftmcf {
+ > .preview {
+ height: 150px;
+ }
+}
+</style>
diff --git a/src/client/pages/page-editor/els/page-editor.el.number-input.vue b/src/client/pages/page-editor/els/page-editor.el.number-input.vue
new file mode 100644
index 0000000000..76dd254464
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.number-input.vue
@@ -0,0 +1,43 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.numberInput') }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._numberInput.name') }}</span></mk-input>
+ <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._numberInput.text') }}</span></mk-input>
+ <mk-input v-model="value.default" type="number"><span>{{ $t('_pages.blocks._numberInput.default') }}</span></mk-input>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '../../../components/ui/input.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faBolt, faMagic
+ };
+ },
+
+ created() {
+ if (this.value.name == null) Vue.set(this.value, 'name', '');
+ },
+});
+</script>
diff --git a/src/client/pages/page-editor/els/page-editor.el.post.vue b/src/client/pages/page-editor/els/page-editor.el.post.vue
new file mode 100644
index 0000000000..10ec885d0f
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.post.vue
@@ -0,0 +1,41 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faPaperPlane"/> {{ $t('_pages.blocks.post') }}</template>
+
+ <section style="padding: 0 16px 16px 16px;">
+ <mk-textarea v-model="value.text">{{ $t('_pages.blocks._post.text') }}</mk-textarea>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPaperPlane } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '../../../components/ui/textarea.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkTextarea
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faPaperPlane
+ };
+ },
+
+ created() {
+ if (this.value.text == null) Vue.set(this.value, 'text', '');
+ },
+});
+</script>
diff --git a/src/client/pages/page-editor/els/page-editor.el.radio-button.vue b/src/client/pages/page-editor/els/page-editor.el.radio-button.vue
new file mode 100644
index 0000000000..8d404ec0df
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.radio-button.vue
@@ -0,0 +1,50 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.radioButton') }}</template>
+
+ <section style="padding: 0 16px 16px 16px;">
+ <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._radioButton.name') }}</span></mk-input>
+ <mk-input v-model="value.title"><span>{{ $t('_pages.blocks._radioButton.title') }}</span></mk-input>
+ <mk-textarea v-model="values"><span>{{ $t('_pages.blocks._radioButton.values') }}</span></mk-textarea>
+ <mk-input v-model="value.default"><span>{{ $t('_pages.blocks._radioButton.default') }}</span></mk-input>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '../../../components/ui/textarea.vue';
+import MkInput from '../../../components/ui/input.vue';
+
+export default Vue.extend({
+ i18n,
+ components: {
+ XContainer, MkTextarea, MkInput
+ },
+ props: {
+ value: {
+ required: true
+ },
+ },
+ data() {
+ return {
+ values: '',
+ faBolt, faMagic
+ };
+ },
+ watch: {
+ values() {
+ Vue.set(this.value, 'values', this.values.split('\n'));
+ }
+ },
+ created() {
+ if (this.value.name == null) Vue.set(this.value, 'name', '');
+ if (this.value.title == null) Vue.set(this.value, 'title', '');
+ if (this.value.values == null) Vue.set(this.value, 'values', []);
+ this.values = this.value.values.join('\n');
+ },
+});
+</script>
diff --git a/src/client/pages/page-editor/els/page-editor.el.section.vue b/src/client/pages/page-editor/els/page-editor.el.section.vue
new file mode 100644
index 0000000000..d405ee1965
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.section.vue
@@ -0,0 +1,104 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faStickyNote"/> {{ value.title }}</template>
+ <template #func>
+ <button @click="rename()">
+ <fa :icon="faPencilAlt"/>
+ </button>
+ <button @click="add()">
+ <fa :icon="faPlus"/>
+ </button>
+ </template>
+
+ <section class="ilrvjyvi">
+ <x-blocks class="children" v-model="value.children" :ai-script="aiScript"/>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { v4 as uuid } from 'uuid';
+import { faPlus, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
+import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer
+ },
+
+ inject: ['getPageBlockList'],
+
+ props: {
+ value: {
+ required: true
+ },
+ aiScript: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ faStickyNote, faPlus, faPencilAlt
+ };
+ },
+
+ beforeCreate() {
+ this.$options.components.XBlocks = require('../page-editor.blocks.vue').default
+ },
+
+ created() {
+ if (this.value.title == null) Vue.set(this.value, 'title', null);
+ if (this.value.children == null) Vue.set(this.value, 'children', []);
+ },
+
+ mounted() {
+ if (this.value.title == null) {
+ this.rename();
+ }
+ },
+
+ methods: {
+ async rename() {
+ const { canceled, result: title } = await this.$root.dialog({
+ title: 'Enter title',
+ input: {
+ type: 'text',
+ default: this.value.title
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.value.title = title;
+ },
+
+ async add() {
+ const { canceled, result: type } = await this.$root.dialog({
+ type: null,
+ title: this.$t('choose-block'),
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.value.children.push({ id, type });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ilrvjyvi {
+ > .children {
+ padding: 16px;
+ }
+}
+</style>
diff --git a/src/client/pages/page-editor/els/page-editor.el.switch.vue b/src/client/pages/page-editor/els/page-editor.el.switch.vue
new file mode 100644
index 0000000000..8f169c3d23
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.switch.vue
@@ -0,0 +1,50 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.switch') }}</template>
+
+ <section class="kjuadyyj">
+ <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._switch.name') }}</span></mk-input>
+ <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._switch.text') }}</span></mk-input>
+ <mk-switch v-model="value.default"><span>{{ $t('_pages.blocks._switch.default') }}</span></mk-switch>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkSwitch from '../../../components/ui/switch.vue';
+import MkInput from '../../../components/ui/input.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkSwitch, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faBolt, faMagic
+ };
+ },
+
+ created() {
+ if (this.value.name == null) Vue.set(this.value, 'name', '');
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.kjuadyyj {
+ padding: 0 16px 16px 16px;
+}
+</style>
diff --git a/src/client/pages/page-editor/els/page-editor.el.text-input.vue b/src/client/pages/page-editor/els/page-editor.el.text-input.vue
new file mode 100644
index 0000000000..7c9e3d6a0e
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.text-input.vue
@@ -0,0 +1,43 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textInput') }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textInput.name') }}</span></mk-input>
+ <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._textInput.text') }}</span></mk-input>
+ <mk-input v-model="value.default" type="text"><span>{{ $t('_pages.blocks._textInput.default') }}</span></mk-input>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '../../../components/ui/input.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faBolt, faMagic
+ };
+ },
+
+ created() {
+ if (this.value.name == null) Vue.set(this.value, 'name', '');
+ },
+});
+</script>
diff --git a/src/client/pages/page-editor/els/page-editor.el.text.vue b/src/client/pages/page-editor/els/page-editor.el.text.vue
new file mode 100644
index 0000000000..00b6cd8a36
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.text.vue
@@ -0,0 +1,60 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.text') }}</template>
+
+ <section class="ihymsbbe">
+ <textarea v-model="value.text"></textarea>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faAlignLeft } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faAlignLeft,
+ };
+ },
+
+ created() {
+ if (this.value.text == null) Vue.set(this.value, 'text', '');
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ihymsbbe {
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ min-width: 100%;
+ min-height: 150px;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ background: transparent;
+ color: var(--fg);
+ font-size: 14px;
+ }
+}
+</style>
diff --git a/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue b/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue
new file mode 100644
index 0000000000..8081e706bc
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue
@@ -0,0 +1,44 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faBolt"/> {{ $t('_pages.blocks.textareaInput') }}</template>
+
+ <section style="padding: 0 16px 16px 16px;">
+ <mk-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('_pages.blocks._textareaInput.name') }}</span></mk-input>
+ <mk-input v-model="value.text"><span>{{ $t('_pages.blocks._textareaInput.text') }}</span></mk-input>
+ <mk-textarea v-model="value.default"><span>{{ $t('_pages.blocks._textareaInput.default') }}</span></mk-textarea>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '../../../components/ui/textarea.vue';
+import MkInput from '../../../components/ui/input.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkTextarea, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faBolt, faMagic
+ };
+ },
+
+ created() {
+ if (this.value.name == null) Vue.set(this.value, 'name', '');
+ },
+});
+</script>
diff --git a/src/client/pages/page-editor/els/page-editor.el.textarea.vue b/src/client/pages/page-editor/els/page-editor.el.textarea.vue
new file mode 100644
index 0000000000..fd75849684
--- /dev/null
+++ b/src/client/pages/page-editor/els/page-editor.el.textarea.vue
@@ -0,0 +1,60 @@
+<template>
+<x-container @remove="() => $emit('remove')" :draggable="true">
+ <template #header><fa :icon="faAlignLeft"/> {{ $t('_pages.blocks.textarea') }}</template>
+
+ <section class="ihymsbbe">
+ <textarea v-model="value.text"></textarea>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faAlignLeft } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../../i18n';
+import XContainer from '../page-editor.container.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ faAlignLeft,
+ };
+ },
+
+ created() {
+ if (this.value.text == null) Vue.set(this.value, 'text', '');
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ihymsbbe {
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ min-width: 100%;
+ min-height: 150px;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ background: transparent;
+ color: var(--fg);
+ font-size: 14px;
+ }
+}
+</style>
diff --git a/src/client/pages/page-editor/page-editor.blocks.vue b/src/client/pages/page-editor/page-editor.blocks.vue
new file mode 100644
index 0000000000..4d7293231f
--- /dev/null
+++ b/src/client/pages/page-editor/page-editor.blocks.vue
@@ -0,0 +1,66 @@
+<template>
+<x-draggable tag="div" :list="blocks" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5">
+ <component v-for="block in blocks" :is="'x-' + block.type" :value="block" @input="updateItem" @remove="() => removeItem(block)" :key="block.id" :ai-script="aiScript"/>
+</x-draggable>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import XSection from './els/page-editor.el.section.vue';
+import XText from './els/page-editor.el.text.vue';
+import XTextarea from './els/page-editor.el.textarea.vue';
+import XImage from './els/page-editor.el.image.vue';
+import XButton from './els/page-editor.el.button.vue';
+import XTextInput from './els/page-editor.el.text-input.vue';
+import XTextareaInput from './els/page-editor.el.textarea-input.vue';
+import XNumberInput from './els/page-editor.el.number-input.vue';
+import XSwitch from './els/page-editor.el.switch.vue';
+import XIf from './els/page-editor.el.if.vue';
+import XPost from './els/page-editor.el.post.vue';
+import XCounter from './els/page-editor.el.counter.vue';
+import XRadioButton from './els/page-editor.el.radio-button.vue';
+
+export default Vue.extend({
+ components: {
+ XDraggable, XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton
+ },
+
+ props: {
+ value: {
+ type: Array,
+ required: true
+ },
+ aiScript: {
+ required: true,
+ },
+ },
+
+ computed: {
+ blocks() {
+ return this.value;
+ }
+ },
+
+ methods: {
+ updateItem(v) {
+ const i = this.blocks.findIndex(x => x.id === v.id);
+ const newValue = [
+ ...this.blocks.slice(0, i),
+ v,
+ ...this.blocks.slice(i + 1)
+ ];
+ this.$emit('input', newValue);
+ },
+
+ removeItem(el) {
+ const i = this.blocks.findIndex(x => x.id === el.id);
+ const newValue = [
+ ...this.blocks.slice(0, i),
+ ...this.blocks.slice(i + 1)
+ ];
+ this.$emit('input', newValue);
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/page-editor/page-editor.container.vue b/src/client/pages/page-editor/page-editor.container.vue
new file mode 100644
index 0000000000..5a4f096c7f
--- /dev/null
+++ b/src/client/pages/page-editor/page-editor.container.vue
@@ -0,0 +1,152 @@
+<template>
+<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
+ <header>
+ <div class="title"><slot name="header"></slot></div>
+ <div class="buttons">
+ <slot name="func"></slot>
+ <button v-if="removable" @click="remove()" class="_button">
+ <fa :icon="faTrashAlt"/>
+ </button>
+ <button v-if="draggable" class="drag-handle _button">
+ <fa :icon="faBars"/>
+ </button>
+ <button @click="toggleContent(!showBody)" class="_button">
+ <template v-if="showBody"><fa :icon="faAngleUp"/></template>
+ <template v-else><fa :icon="faAngleDown"/></template>
+ </button>
+ </div>
+ </header>
+ <p v-show="showBody" class="error" v-if="error != null">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p>
+ <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
+ <div v-show="showBody">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBars, faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
+import { faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ props: {
+ expanded: {
+ type: Boolean,
+ default: true
+ },
+ removable: {
+ type: Boolean,
+ default: true
+ },
+ draggable: {
+ type: Boolean,
+ default: false
+ },
+ error: {
+ required: false,
+ default: null
+ },
+ warn: {
+ required: false,
+ default: null
+ }
+ },
+ data() {
+ return {
+ showBody: this.expanded,
+ faTrashAlt, faBars, faAngleUp, faAngleDown
+ };
+ },
+ methods: {
+ toggleContent(show: boolean) {
+ this.showBody = show;
+ this.$emit('toggle', show);
+ },
+ remove() {
+ this.$emit('remove');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cpjygsrt {
+ position: relative;
+ overflow: hidden;
+ background: var(--panel);
+ border: solid 2px var(--jvhmlskx);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 2px var(--yakfpmhl);
+ }
+
+ &.warn {
+ border: solid 2px #dec44c;
+ }
+
+ &.error {
+ border: solid 2px #f00;
+ }
+
+ & + .cpjygsrt {
+ margin-top: 16px;
+ }
+
+ > header {
+ > .title {
+ z-index: 1;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 42px;
+ font-size: 0.9em;
+ font-weight: bold;
+ box-shadow: 0 1px rgba(#000, 0.07);
+
+ > [data-icon] {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .buttons {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+
+ > button {
+ padding: 0;
+ width: 42px;
+ font-size: 0.9em;
+ line-height: 42px;
+ }
+
+ .drag-handle {
+ cursor: move;
+ }
+ }
+ }
+
+ > .warn {
+ color: #b19e49;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ font-size: 14px;
+ }
+
+ > .error {
+ color: #f00;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ font-size: 14px;
+ }
+}
+</style>
diff --git a/src/client/pages/page-editor/page-editor.script-block.vue b/src/client/pages/page-editor/page-editor.script-block.vue
new file mode 100644
index 0000000000..ae56803a39
--- /dev/null
+++ b/src/client/pages/page-editor/page-editor.script-block.vue
@@ -0,0 +1,278 @@
+<template>
+<x-container :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable">
+ <template #header><fa v-if="icon" :icon="icon"/> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template>
+ <template #func>
+ <button @click="changeType()">
+ <fa :icon="faPencilAlt"/>
+ </button>
+ </template>
+
+ <section v-if="value.type === null" class="pbglfege" @click="changeType()">
+ {{ $t('_pages.script.emptySlot') }}
+ </section>
+ <section v-else-if="value.type === 'text'" class="tbwccoaw">
+ <input v-model="value.value"/>
+ </section>
+ <section v-else-if="value.type === 'multiLineText'" class="tbwccoaw">
+ <textarea v-model="value.value"></textarea>
+ </section>
+ <section v-else-if="value.type === 'textList'" class="tbwccoaw">
+ <textarea v-model="value.value" :placeholder="$t('_pages.script.blocks._textList.info')"></textarea>
+ </section>
+ <section v-else-if="value.type === 'number'" class="tbwccoaw">
+ <input v-model="value.value" type="number"/>
+ </section>
+ <section v-else-if="value.type === 'ref'" class="hpdwcrvs">
+ <select v-model="value.value">
+ <option v-for="v in aiScript.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$t('_pages.script.argVariables')">
+ <option v-for="v in fnSlots" :value="v.name">{{ v.name }}</option>
+ </optgroup>
+ <optgroup :label="$t('_pages.script.pageVariables')">
+ <option v-for="v in aiScript.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$t('_pages.script.enviromentVariables')">
+ <option v-for="v in aiScript.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
+ </optgroup>
+ </select>
+ </section>
+ <section v-else-if="value.type === 'fn'" class="" style="padding:0 16px 16px 16px;">
+ <mk-textarea v-model="slots">
+ <span>{{ $t('_pages.script.blocks._fn.slots') }}</span>
+ <template #desc>{{ $t('_pages.script.blocks._fn.slots-info') }}</template>
+ </mk-textarea>
+ <x-v v-if="value.value.expression" v-model="value.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :ai-script="aiScript" :fn-slots="value.value.slots" :name="name"/>
+ </section>
+ <section v-else-if="value.type.startsWith('fn:')" class="" style="padding:16px;">
+ <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="aiScript.getVarByName(value.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :ai-script="aiScript" :name="name" :key="i"/>
+ </section>
+ <section v-else class="" style="padding:16px;">
+ <x-v v-for="(x, i) in value.args" v-model="value.args[i]" :title="$t(`_pages.script.blocks._${value.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :ai-script="aiScript" :name="name" :fn-slots="fnSlots" :key="i"/>
+ </section>
+</x-container>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPencilAlt, faPlug } from '@fortawesome/free-solid-svg-icons';
+import { v4 as uuid } from 'uuid';
+import i18n from '../../i18n';
+import XContainer from './page-editor.container.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import { isLiteralBlock, funcDefs, blockDefs } from '../../scripts/aiscript/index';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XContainer, MkTextarea
+ },
+
+ inject: ['getScriptBlockList'],
+
+ props: {
+ getExpectedType: {
+ required: false,
+ default: null
+ },
+ value: {
+ required: true
+ },
+ title: {
+ required: false
+ },
+ removable: {
+ required: false,
+ default: false
+ },
+ aiScript: {
+ required: true,
+ },
+ name: {
+ required: true,
+ },
+ fnSlots: {
+ required: false,
+ },
+ draggable: {
+ required: false,
+ default: false
+ }
+ },
+
+ data() {
+ return {
+ error: null,
+ warn: null,
+ slots: '',
+ faPencilAlt
+ };
+ },
+
+ computed: {
+ icon(): any {
+ if (this.value.type === null) return null;
+ if (this.value.type.startsWith('fn:')) return faPlug;
+ return blockDefs.find(x => x.type === this.value.type).icon;
+ },
+ typeText(): any {
+ if (this.value.type === null) return null;
+ if (this.value.type.startsWith('fn:')) return this.value.type.split(':')[1];
+ return this.$t(`_pages.script.blocks.${this.value.type}`);
+ },
+ },
+
+ watch: {
+ slots() {
+ this.value.value.slots = this.slots.split('\n').map(x => ({
+ name: x,
+ type: null
+ }));
+ }
+ },
+
+ beforeCreate() {
+ this.$options.components.XV = require('./page-editor.script-block.vue').default;
+ },
+
+ created() {
+ if (this.value.value == null) Vue.set(this.value, 'value', null);
+
+ if (this.value.value && this.value.value.slots) this.slots = this.value.value.slots.map(x => x.name).join('\n');
+
+ this.$watch('value.type', (t) => {
+ this.warn = null;
+
+ if (this.value.type === 'fn') {
+ const id = uuid();
+ this.value.value = {};
+ Vue.set(this.value.value, 'slots', []);
+ Vue.set(this.value.value, 'expression', { id, type: null });
+ return;
+ }
+
+ if (this.value.type && this.value.type.startsWith('fn:')) {
+ const fnName = this.value.type.split(':')[1];
+ const fn = this.aiScript.getVarByName(fnName);
+
+ const empties = [];
+ for (let i = 0; i < fn.value.slots.length; i++) {
+ const id = uuid();
+ empties.push({ id, type: null });
+ }
+ Vue.set(this.value, 'args', empties);
+ return;
+ }
+
+ if (isLiteralBlock(this.value)) return;
+
+ const empties = [];
+ for (let i = 0; i < funcDefs[this.value.type].in.length; i++) {
+ const id = uuid();
+ empties.push({ id, type: null });
+ }
+ Vue.set(this.value, 'args', empties);
+
+ for (let i = 0; i < funcDefs[this.value.type].in.length; i++) {
+ const inType = funcDefs[this.value.type].in[i];
+ if (typeof inType !== 'number') {
+ if (inType === 'number') this.value.args[i].type = 'number';
+ if (inType === 'string') this.value.args[i].type = 'text';
+ }
+ }
+ });
+
+ this.$watch('value.args', (args) => {
+ if (args == null) {
+ this.warn = null;
+ return;
+ }
+ const emptySlotIndex = args.findIndex(x => x.type === null);
+ if (emptySlotIndex !== -1 && emptySlotIndex < args.length) {
+ this.warn = {
+ slot: emptySlotIndex
+ };
+ } else {
+ this.warn = null;
+ }
+ }, {
+ deep: true
+ });
+
+ this.$watch('aiScript.variables', () => {
+ if (this.type != null && this.value) {
+ this.error = this.aiScript.typeCheck(this.value);
+ }
+ }, {
+ deep: true
+ });
+ },
+
+ methods: {
+ async changeType() {
+ const { canceled, result: type } = await this.$root.dialog({
+ type: null,
+ title: this.$t('select-type'),
+ select: {
+ groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null)
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.value.type = type;
+ },
+
+ _getExpectedType(slot: number) {
+ return this.aiScript.getExpectedType(this.value, slot);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.turmquns {
+ opacity: 0.7;
+}
+
+.pbglfege {
+ opacity: 0.5;
+ padding: 16px;
+ text-align: center;
+ cursor: pointer;
+ color: var(--fg);
+}
+
+.tbwccoaw {
+ > input,
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ font-size: 16px;
+ background: transparent;
+ color: var(--fg);
+ }
+
+ > textarea {
+ min-height: 100px;
+ }
+}
+
+.hpdwcrvs {
+ padding: 16px;
+
+ > select {
+ display: block;
+ padding: 4px;
+ font-size: 16px;
+ width: 100%;
+ }
+}
+</style>
diff --git a/src/client/pages/page-editor/page-editor.vue b/src/client/pages/page-editor/page-editor.vue
new file mode 100644
index 0000000000..a5a4588f13
--- /dev/null
+++ b/src/client/pages/page-editor/page-editor.vue
@@ -0,0 +1,516 @@
+<template>
+<div>
+ <div class="gwbmwxkm _panel">
+ <header>
+ <div class="title"><fa :icon="faStickyNote"/> {{ readonly ? $t('read-page') : pageId ? $t('edit-page') : $t('new-page') }}</div>
+ <div class="buttons">
+ <button class="_button" @click="del()" v-if="!readonly"><fa :icon="faTrashAlt"/></button>
+ <button class="_button" @click="() => showOptions = !showOptions"><fa :icon="faCog"/></button>
+ <button class="_button" @click="save()" v-if="!readonly"><fa :icon="faSave"/></button>
+ </div>
+ </header>
+
+ <section>
+ <router-link class="view" v-if="pageId" :to="`/@${ author.username }/pages/${ currentName }`"><fa :icon="faExternalLinkSquareAlt"/> {{ $t('view-page') }}</router-link>
+
+ <mk-input v-model="title">
+ <span>{{ $t('title') }}</span>
+ </mk-input>
+
+ <template v-if="showOptions">
+ <mk-input v-model="summary">
+ <span>{{ $t('summary') }}</span>
+ </mk-input>
+
+ <mk-input v-model="name">
+ <template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
+ <span>{{ $t('url') }}</span>
+ </mk-input>
+
+ <mk-switch v-model="alignCenter">{{ $t('align-center') }}</mk-switch>
+
+ <mk-select v-model="font">
+ <template #label>{{ $t('font') }}</template>
+ <option value="serif">{{ $t('fontSerif') }}</option>
+ <option value="sans-serif">{{ $t('fontSansSerif') }}</option>
+ </mk-select>
+
+ <mk-switch v-model="hideTitleWhenPinned">{{ $t('hide-title-when-pinned') }}</mk-switch>
+
+ <div class="eyeCatch">
+ <mk-button v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage()"><fa :icon="faPlus"/> {{ $t('set-eye-catching-image') }}</mk-button>
+ <div v-else-if="eyeCatchingImage">
+ <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name"/>
+ <mk-button @click="removeEyeCatchingImage()" v-if="!readonly"><fa :icon="faTrashAlt"/> {{ $t('remove-eye-catching-image') }}</mk-button>
+ </div>
+ </div>
+ </template>
+
+ <x-blocks class="content" v-model="content" :ai-script="aiScript"/>
+
+ <mk-button @click="add()" v-if="!readonly"><fa :icon="faPlus"/></mk-button>
+ </section>
+ </div>
+
+ <mk-container :body-togglable="true">
+ <template #header><fa :icon="faMagic"/> {{ $t('variables') }}</template>
+ <div class="qmuvgica">
+ <x-draggable tag="div" class="variables" v-show="variables.length > 0" :list="variables" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
+ <x-variable v-for="variable in variables"
+ :value="variable"
+ :removable="true"
+ @input="v => updateVariable(v)"
+ @remove="() => removeVariable(variable)"
+ :key="variable.name"
+ :ai-script="aiScript"
+ :name="variable.name"
+ :title="variable.name"
+ :draggable="true"
+ />
+ </x-draggable>
+
+ <mk-button @click="addVariable()" class="add" v-if="!readonly"><fa :icon="faPlus"/></mk-button>
+
+ <x-info><span v-html="$t('variables-info')"></span><a @click="() => moreDetails = true" style="display:block;">{{ $t('more-details') }}</a></x-info>
+
+ <template v-if="moreDetails">
+ <x-info><span v-html="$t('variables-info2')"></span></x-info>
+ <x-info><span v-html="$t('variables-info3')"></span></x-info>
+ <x-info><span v-html="$t('variables-info4')"></span></x-info>
+ </template>
+ </div>
+ </mk-container>
+
+ <mk-container :body-togglable="true" :expanded="false">
+ <template #header><fa :icon="faCode"/> {{ $t('inspector') }}</template>
+ <div style="padding:0 32px 32px 32px;">
+ <mk-textarea :value="JSON.stringify(content, null, 2)" readonly tall>{{ $t('content') }}</mk-textarea>
+ <mk-textarea :value="JSON.stringify(variables, null, 2)" readonly tall>{{ $t('variables') }}</mk-textarea>
+ </div>
+ </mk-container>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import * as XDraggable from 'vuedraggable';
+import { faICursor, faPlus, faMagic, faCog, faCode, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
+import { faSave, faStickyNote, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import { v4 as uuid } from 'uuid';
+import i18n from '../../i18n';
+import XVariable from './page-editor.script-block.vue';
+import XBlocks from './page-editor.blocks.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkContainer from '../../components/ui/container.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkSelect from '../../components/ui/select.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import MkInput from '../../components/ui/input.vue';
+import { blockDefs } from '../../scripts/aiscript/index';
+import { ASTypeChecker } from '../../scripts/aiscript/type-checker';
+import { url } from '../../config';
+import { collectPageVars } from '../../scripts/collect-page-vars';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XDraggable, XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput
+ },
+
+ props: {
+ initPageId: {
+ type: String,
+ required: false
+ },
+ initPageName: {
+ type: String,
+ required: false
+ },
+ initUser: {
+ type: String,
+ required: false
+ },
+ },
+
+ data() {
+ return {
+ author: this.$store.state.i,
+ readonly: false,
+ page: null,
+ pageId: null,
+ currentName: null,
+ title: '',
+ summary: null,
+ name: Date.now().toString(),
+ eyeCatchingImage: null,
+ eyeCatchingImageId: null,
+ font: 'sans-serif',
+ content: [],
+ alignCenter: false,
+ hideTitleWhenPinned: false,
+ variables: [],
+ aiScript: null,
+ showOptions: false,
+ moreDetails: false,
+ url,
+ faPlus, faICursor, faSave, faStickyNote, faMagic, faCog, faTrashAlt, faExternalLinkSquareAlt, faCode
+ };
+ },
+
+ watch: {
+ async eyeCatchingImageId() {
+ if (this.eyeCatchingImageId == null) {
+ this.eyeCatchingImage = null;
+ } else {
+ this.eyeCatchingImage = await this.$root.api('drive/files/show', {
+ fileId: this.eyeCatchingImageId,
+ });
+ }
+ },
+ },
+
+ async created() {
+ this.aiScript = new ASTypeChecker();
+
+ this.$watch('variables', () => {
+ this.aiScript.variables = this.variables;
+ }, { deep: true });
+
+ this.$watch('content', () => {
+ this.aiScript.pageVars = collectPageVars(this.content);
+ }, { deep: true });
+
+ if (this.initPageId) {
+ this.page = await this.$root.api('pages/show', {
+ pageId: this.initPageId,
+ });
+ } else if (this.initPageName && this.initUser) {
+ this.page = await this.$root.api('pages/show', {
+ name: this.initPageName,
+ username: this.initUser,
+ });
+ this.readonly = true;
+ }
+
+ if (this.page) {
+ this.author = this.page.user;
+ this.pageId = this.page.id;
+ this.title = this.page.title;
+ this.name = this.page.name;
+ this.currentName = this.page.name;
+ this.summary = this.page.summary;
+ this.font = this.page.font;
+ this.hideTitleWhenPinned = this.page.hideTitleWhenPinned;
+ this.alignCenter = this.page.alignCenter;
+ this.content = this.page.content;
+ this.variables = this.page.variables;
+ this.eyeCatchingImageId = this.page.eyeCatchingImageId;
+ } else {
+ const id = uuid();
+ this.content = [{
+ id,
+ type: 'text',
+ text: 'Hello World!'
+ }];
+ }
+ },
+
+ provide() {
+ return {
+ readonly: this.readonly,
+ getScriptBlockList: this.getScriptBlockList,
+ getPageBlockList: this.getPageBlockList
+ }
+ },
+
+ methods: {
+ save() {
+ const options = {
+ title: this.title.trim(),
+ name: this.name.trim(),
+ summary: this.summary,
+ font: this.font,
+ hideTitleWhenPinned: this.hideTitleWhenPinned,
+ alignCenter: this.alignCenter,
+ content: this.content,
+ variables: this.variables,
+ eyeCatchingImageId: this.eyeCatchingImageId,
+ };
+
+ const onError = err => {
+ if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
+ if (err.info.param == 'name') {
+ this.$root.dialog({
+ type: 'error',
+ title: this.$t('title-invalid-name'),
+ text: this.$t('text-invalid-name')
+ });
+ }
+ } else if (err.code == 'NAME_ALREADY_EXISTS') {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('name-already-exists')
+ });
+ }
+ };
+
+ if (this.pageId) {
+ options.pageId = this.pageId;
+ this.$root.api('pages/update', options)
+ .then(page => {
+ this.currentName = this.name.trim();
+ this.$root.dialog({
+ type: 'success',
+ text: this.$t('page-updated')
+ });
+ }).catch(onError);
+ } else {
+ this.$root.api('pages/create', options)
+ .then(page => {
+ this.pageId = page.id;
+ this.currentName = this.name.trim();
+ this.$root.dialog({
+ type: 'success',
+ text: this.$t('page-created')
+ });
+ this.$router.push(`/my/pages/edit/${this.pageId}`);
+ }).catch(onError);
+ }
+ },
+
+ del() {
+ this.$root.dialog({
+ type: 'warning',
+ text: this.$t('are-you-sure-delete'),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.$root.api('pages/delete', {
+ pageId: this.pageId,
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ text: this.$t('page-deleted')
+ });
+ this.$router.push(`/my/pages`);
+ });
+ });
+ },
+
+ async add() {
+ const { canceled, result: type } = await this.$root.dialog({
+ type: null,
+ title: this.$t('choose-block'),
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.content.push({ id, type });
+ },
+
+ async addVariable() {
+ let { canceled, result: name } = await this.$root.dialog({
+ title: this.$t('enter-variable-name'),
+ input: {
+ type: 'text',
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ name = name.trim();
+
+ if (this.aiScript.isUsedName(name)) {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('the-variable-name-is-already-used')
+ });
+ return;
+ }
+
+ const id = uuid();
+ this.variables.push({ id, name, type: null });
+ },
+
+ removeVariable(v) {
+ const i = this.variables.findIndex(x => x.name === v.name);
+ const newValue = [
+ ...this.variables.slice(0, i),
+ ...this.variables.slice(i + 1)
+ ];
+ this.variables = newValue;
+ },
+
+ getPageBlockList() {
+ return [{
+ label: this.$t('content-blocks'),
+ items: [
+ { value: 'section', text: this.$t('_pages.blocks.section') },
+ { value: 'text', text: this.$t('_pages.blocks.text') },
+ { value: 'image', text: this.$t('_pages.blocks.image') },
+ { value: 'textarea', text: this.$t('_pages.blocks.textarea') },
+ ]
+ }, {
+ label: this.$t('input-blocks'),
+ items: [
+ { value: 'button', text: this.$t('_pages.blocks.button') },
+ { value: 'radioButton', text: this.$t('_pages.blocks.radioButton') },
+ { value: 'textInput', text: this.$t('_pages.blocks.textInput') },
+ { value: 'textareaInput', text: this.$t('_pages.blocks.textareaInput') },
+ { value: 'numberInput', text: this.$t('_pages.blocks.numberInput') },
+ { value: 'switch', text: this.$t('_pages.blocks.switch') },
+ { value: 'counter', text: this.$t('_pages.blocks.counter') }
+ ]
+ }, {
+ label: this.$t('special-blocks'),
+ items: [
+ { value: 'if', text: this.$t('_pages.blocks.if') },
+ { value: 'post', text: this.$t('_pages.blocks.post') }
+ ]
+ }];
+ },
+
+ getScriptBlockList(type: string = null) {
+ const list = [];
+
+ const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
+
+ for (const block of blocks) {
+ const category = list.find(x => x.category === block.category);
+ if (category) {
+ category.items.push({
+ value: block.type,
+ text: this.$t(`_pages.script.blocks.${block.type}`)
+ });
+ } else {
+ list.push({
+ category: block.category,
+ label: this.$t(`script.categories.${block.category}`),
+ items: [{
+ value: block.type,
+ text: this.$t(`_pages.script.blocks.${block.type}`)
+ }]
+ });
+ }
+ }
+
+ const userFns = this.variables.filter(x => x.type === 'fn');
+ if (userFns.length > 0) {
+ list.unshift({
+ label: this.$t(`script.categories.fn`),
+ items: userFns.map(v => ({
+ value: 'fn:' + v.name,
+ text: v.name
+ }))
+ });
+ }
+
+ return list;
+ },
+
+ setEyeCatchingImage() {
+ this.$chooseDriveFile({
+ multiple: false
+ }).then(file => {
+ this.eyeCatchingImageId = file.id;
+ });
+ },
+
+ removeEyeCatchingImage() {
+ this.eyeCatchingImageId = null;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.gwbmwxkm {
+ margin-bottom: var(--margin);
+
+ > header {
+ background: var(--faceHeader);
+
+ > .title {
+ z-index: 1;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 42px;
+ font-size: 0.9em;
+ font-weight: bold;
+ color: var(--faceHeaderText);
+ box-shadow: 0 var(--lineWidth) rgba(#000, 0.07);
+
+ > [data-icon] {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .buttons {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+
+ > button {
+ padding: 0;
+ width: 42px;
+ font-size: 0.9em;
+ line-height: 42px;
+ }
+ }
+ }
+
+ > section {
+ padding: 0 32px 32px 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px 16px 16px;
+ }
+
+ > .view {
+ display: inline-block;
+ margin: 16px 0 0 0;
+ font-size: 14px;
+ }
+
+ > .content {
+ margin-bottom: 16px;
+ }
+
+ > .eyeCatch {
+ margin-bottom: 16px;
+
+ > div {
+ > img {
+ max-width: 100%;
+ }
+ }
+ }
+ }
+}
+
+.qmuvgica {
+ padding: 32px;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ }
+
+ > .variables {
+ margin-bottom: 16px;
+ }
+
+ > .add {
+ margin-bottom: 16px;
+ }
+}
+</style>
diff --git a/src/client/pages/page.vue b/src/client/pages/page.vue
new file mode 100644
index 0000000000..72c5101731
--- /dev/null
+++ b/src/client/pages/page.vue
@@ -0,0 +1,69 @@
+<template>
+<div class="xcukqgmh _panel">
+ <portal to="avatar" v-if="page"><mk-avatar class="avatar" :user="page.user" :disable-preview="true"/></portal>
+ <portal to="title" v-if="page">{{ page.title || page.name }}</portal>
+
+ <x-page v-if="page" :page="page" :key="page.id" :show-footer="true"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XPage from '../components/page/page.vue';
+
+export default Vue.extend({
+ components: {
+ XPage
+ },
+
+ props: {
+ pageName: {
+ type: String,
+ required: true
+ },
+ username: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ page: null,
+ };
+ },
+
+ computed: {
+ path(): string {
+ return this.username + '/' + this.pageName;
+ }
+ },
+
+ watch: {
+ path() {
+ this.fetch();
+ }
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ this.$root.api('pages/show', {
+ name: this.pageName,
+ username: this.username,
+ }).then(page => {
+ this.page = page;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xcukqgmh {
+
+}
+</style>
diff --git a/src/client/pages/pages.vue b/src/client/pages/pages.vue
new file mode 100644
index 0000000000..bee7d30a61
--- /dev/null
+++ b/src/client/pages/pages.vue
@@ -0,0 +1,78 @@
+<template>
+<div>
+ <mk-container :body-togglable="true">
+ <template #header><fa :icon="faEdit" fixed-width/>{{ $t('my-pages') }}</template>
+ <div class="rknalgpo my">
+ <mk-button class="new" @click="create()"><fa :icon="faPlus"/></mk-button>
+ <mk-pagination :pagination="myPagesPagination" #default="{items}">
+ <mk-page-preview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/>
+ </mk-pagination>
+ </div>
+ </mk-container>
+
+ <mk-container :body-togglable="true">
+ <template #header><fa :icon="faHeart" fixed-width/>{{ $t('liked-pages') }}</template>
+ <div class="rknalgpo">
+ <mk-pagination :pagination="likedPagesPagination" #default="{items}">
+ <mk-page-preview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/>
+ </mk-pagination>
+ </div>
+ </mk-container>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPlus, faEdit } from '@fortawesome/free-solid-svg-icons';
+import { faStickyNote, faHeart } from '@fortawesome/free-regular-svg-icons';
+import i18n from '../i18n';
+import MkPagePreview from '../components/page-preview.vue';
+import MkPagination from '../components/ui/pagination.vue';
+import MkButton from '../components/ui/button.vue';
+import MkContainer from '../components/ui/container.vue';
+
+export default Vue.extend({
+ i18n,
+ components: {
+ MkPagePreview, MkPagination, MkButton, MkContainer
+ },
+ data() {
+ return {
+ myPagesPagination: {
+ endpoint: 'i/pages',
+ limit: 5,
+ },
+ likedPagesPagination: {
+ endpoint: 'i/page-likes',
+ limit: 5,
+ },
+ faStickyNote, faPlus, faEdit, faHeart
+ };
+ },
+ methods: {
+ create() {
+ this.$router.push(`/my/pages/new`);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rknalgpo {
+ padding: 16px;
+
+ &.my .ckltabjg:first-child {
+ margin-top: 16px;
+ }
+
+ .ckltabjg:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ @media (min-width: 500px) {
+ .ckltabjg:not(:last-child) {
+ margin-bottom: 16px;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/search.vue b/src/client/pages/search.vue
new file mode 100644
index 0000000000..c3e87c0d0c
--- /dev/null
+++ b/src/client/pages/search.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+ <portal to="icon"><fa :icon="faSearch"/></portal>
+ <portal to="title">{{ $t('searchWith', { q: $route.query.q }) }}</portal>
+ <x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faSearch } from '@fortawesome/free-solid-svg-icons';
+import Progress from '../scripts/loading';
+import XNotes from '../components/notes.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('searchWith', { q: this.$route.query.q }) as string
+ };
+ },
+
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'notes/search',
+ limit: 10,
+ params: () => ({
+ query: this.$route.query.q,
+ })
+ },
+ faSearch
+ };
+ },
+
+ watch: {
+ $route() {
+ (this.$refs.notes as any).reload();
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/2fa.vue b/src/client/pages/settings/2fa.vue
new file mode 100644
index 0000000000..7163f2ece4
--- /dev/null
+++ b/src/client/pages/settings/2fa.vue
@@ -0,0 +1,264 @@
+<template>
+<section class="_section">
+ <div class="_title"><fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div>
+ <div class="_content">
+ <p v-if="!data && !$store.state.i.twoFactorEnabled"><mk-button @click="register">{{ $t('_2fa.registerDevice') }}</mk-button></p>
+ <template v-if="$store.state.i.twoFactorEnabled">
+ <h2 class="heading">{{ $t('totp-header') }}</h2>
+ <p>{{ $t('already-registered') }}</p>
+ <mk-button @click="unregister">{{ $t('unregister') }}</mk-button>
+
+ <template v-if="supportsCredentials">
+ <hr class="totp-method-sep">
+
+ <h2 class="heading">{{ $t('security-key-header') }}</h2>
+ <p>{{ $t('security-key') }}</p>
+ <div class="key-list">
+ <div class="key" v-for="key in $store.state.i.securityKeysList">
+ <h3>
+ {{ key.name }}
+ </h3>
+ <div class="last-used">
+ {{ $t('last-used') }}
+ <mk-time :time="key.lastUsed"/>
+ </div>
+ <mk-button @click="unregisterKey(key)">
+ {{ $t('unregister') }}
+ </mk-button>
+ </div>
+ </div>
+
+ <mk-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">
+ {{ $t('use-password-less-login') }}
+ </mk-switch>
+
+ <mk-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</mk-info>
+ <mk-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</mk-button>
+
+ <ol v-if="registration && !registration.error">
+ <li v-if="registration.stage >= 0">
+ {{ $t('activate-key') }}
+ <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" />
+ </li>
+ <li v-if="registration.stage >= 1">
+ <mk-form :disabled="registration.stage != 1 || registration.saving">
+ <mk-input v-model="keyName" :max="30">
+ <span>{{ $t('security-key-name') }}</span>
+ </mk-input>
+ <mk-button @click="registerKey" :disabled="this.keyName.length == 0">
+ {{ $t('register-security-key') }}
+ </mk-button>
+ <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" />
+ </mk-form>
+ </li>
+ </ol>
+ </template>
+ </template>
+ <div v-if="data && !$store.state.i.twoFactorEnabled">
+ <ol style="margin: 0; padding: 0 0 0 1em;">
+ <li>
+ <i18n path="_2fa.step1" tag="span">
+ <a href="https://authy.com/" rel="noopener" target="_blank" place="a" style="color: var(--link);">Authy</a>
+ <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" place="b" style="color: var(--link);">Google Authenticator</a>
+ </i18n>
+ </li>
+ <li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li>
+ <li>{{ $t('_2fa.step3') }}<br>
+ <mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</mk-input>
+ <mk-button primary @click="submit">{{ $t('done') }}</mk-button>
+ </li>
+ </ol>
+ <mk-info>{{ $t('_2fa.step4') }}</mk-info>
+ </div>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import i18n from '../../i18n';
+import { hostname } from '../../config';
+import { hexifyAB } from '../../scripts/2fa';
+import MkButton from '../../components/ui/button.vue';
+import MkInfo from '../../components/ui/info.vue';
+import MkInput from '../../components/ui/input.vue';
+
+function stringifyAB(buffer) {
+ return String.fromCharCode.apply(null, new Uint8Array(buffer));
+}
+
+export default Vue.extend({
+ i18n,
+ components: {
+ MkButton, MkInfo, MkInput
+ },
+ data() {
+ return {
+ data: null,
+ supportsCredentials: !!navigator.credentials,
+ usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin,
+ registration: null,
+ keyName: '',
+ token: null,
+ faLock
+ };
+ },
+ methods: {
+ register() {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ this.$root.api('i/2fa/register', {
+ password: password
+ }).then(data => {
+ this.data = data;
+ });
+ });
+ },
+
+ unregister() {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ this.$root.api('i/2fa/unregister', {
+ password: password
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ this.$store.state.i.twoFactorEnabled = false;
+ });
+ });
+ },
+
+ submit() {
+ this.$root.api('i/2fa/done', {
+ token: this.token
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ this.$store.state.i.twoFactorEnabled = true;
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ iconOnly: true, autoClose: true
+ });
+ });
+ },
+
+ registerKey() {
+ this.registration.saving = true;
+ this.$root.api('i/2fa/key-done', {
+ password: this.registration.password,
+ name: this.keyName,
+ challengeId: this.registration.challengeId,
+ // we convert each 16 bits to a string to serialise
+ clientDataJSON: stringifyAB(this.registration.credential.response.clientDataJSON),
+ attestationObject: hexifyAB(this.registration.credential.response.attestationObject)
+ }).then(key => {
+ this.registration = null;
+ key.lastUsed = new Date();
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ })
+ },
+
+ unregisterKey(key) {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ return this.$root.api('i/2fa/remove-key', {
+ password,
+ credentialId: key.id
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ });
+ });
+ },
+
+ addSecurityKey() {
+ this.$root.dialog({
+ title: this.$t('password'),
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ this.$root.api('i/2fa/register-key', {
+ password
+ }).then(registration => {
+ this.registration = {
+ password,
+ challengeId: registration.challengeId,
+ stage: 0,
+ publicKeyOptions: {
+ challenge: Buffer.from(
+ registration.challenge
+ .replace(/\-/g, "+")
+ .replace(/_/g, "/"),
+ 'base64'
+ ),
+ rp: {
+ id: hostname,
+ name: 'Misskey'
+ },
+ user: {
+ id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)),
+ name: this.$store.state.i.username,
+ displayName: this.$store.state.i.name,
+ },
+ pubKeyCredParams: [{alg: -7, type: 'public-key'}],
+ timeout: 60000,
+ attestation: 'direct'
+ },
+ saving: true
+ };
+ return navigator.credentials.create({
+ publicKey: this.registration.publicKeyOptions
+ });
+ }).then(credential => {
+ this.registration.credential = credential;
+ this.registration.saving = false;
+ this.registration.stage = 1;
+ }).catch(err => {
+ console.warn('Error while registering?', err);
+ this.registration.error = err.message;
+ this.registration.stage = -1;
+ });
+ });
+ },
+ updatePasswordLessLogin() {
+ this.$root.api('i/2fa/password-less', {
+ value: !!this.usePasswordLessLogin
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/drive.vue b/src/client/pages/settings/drive.vue
new file mode 100644
index 0000000000..d0c18a07e5
--- /dev/null
+++ b/src/client/pages/settings/drive.vue
@@ -0,0 +1,212 @@
+<template>
+<section class="mk-settings-page-drive _section">
+ <div class="_title"><fa :icon="faCloud"/> {{ $t('drive') }}</div>
+ <div class="_content">
+ <mk-pagination :pagination="drivePagination" #default="{items}" class="drive" ref="drive">
+ <div class="file" v-for="(file, i) in items" :key="file.id" :data-index="i" @click="selected = file" :class="{ selected: selected && (selected.id === file.id) }">
+ <x-file-thumbnail class="thumbnail" :file="file" fit="cover"/>
+ <div class="body">
+ <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>
+ <footer>
+ <span class="type"><x-file-type-icon :type="file.type" class="icon"/>{{ file.type }}</span>
+ <span class="separator"></span>
+ <span class="data-size">{{ file.size | bytes }}</span>
+ <span class="separator"></span>
+ <span class="created-at"><fa :icon="faClock"/><mk-time :time="file.createdAt"/></span>
+ <template v-if="file.isSensitive">
+ <span class="separator"></span>
+ <span class="nsfw"><fa :icon="faEyeSlash"/> {{ $t('nsfw') }}</span>
+ </template>
+ </footer>
+ </div>
+ </div>
+ </mk-pagination>
+ </div>
+ <div class="_footer">
+ <mk-button primary inline :disabled="selected == null" @click="download()"><fa :icon="faDownload"/> {{ $t('download') }}</mk-button>
+ <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCloud, faDownload } from '@fortawesome/free-solid-svg-icons';
+import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons';
+import XFileTypeIcon from '../../components/file-type-icon.vue';
+import XFileThumbnail from '../../components/drive-file-thumbnail.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ XFileTypeIcon,
+ XFileThumbnail,
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ selected: null,
+ connection: null,
+ drivePagination: {
+ endpoint: 'drive/files',
+ limit: 10,
+ },
+ faCloud, faClock, faEyeSlash, faDownload, faTrashAlt
+ }
+ },
+
+ created() {
+ this.connection = this.$root.stream.useSharedConnection('drive');
+
+ this.connection.on('fileCreated', this.onStreamDriveFileCreated);
+ this.connection.on('fileUpdated', this.onStreamDriveFileUpdated);
+ this.connection.on('fileDeleted', this.onStreamDriveFileDeleted);
+ },
+
+ beforeDestroy() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ onStreamDriveFileCreated(file) {
+ this.$refs.drive.prepend(file);
+ },
+
+ onStreamDriveFileUpdated(file) {
+ // TODO
+ },
+
+ onStreamDriveFileDeleted(fileId) {
+ this.$refs.drive.remove(x => x.id === fileId);
+ },
+
+ download() {
+ window.open(this.selected.url, '_blank');
+ },
+
+ async del() {
+ const { canceled } = await this.$root.dialog({
+ type: 'warning',
+ text: this.$t('driveFileDeleteConfirm', { name: this.selected.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ this.$root.api('drive/files/delete', {
+ fileId: this.selected.id
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-settings-page-drive {
+ > ._content {
+ max-height: 350px;
+ overflow: auto;
+
+ > .drive {
+ > .file {
+ display: grid;
+ margin: 0 auto;
+ grid-template-columns: 64px 1fr;
+ grid-column-gap: 10px;
+ cursor: pointer;
+
+ &.selected {
+ background: var(--accent);
+ box-shadow: 0 0 0 8px var(--accent);
+ color: #fff;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 16px;
+ }
+
+ > .thumbnail {
+ width: 64px;
+ height: 64px;
+ }
+
+ > .body {
+ display: block;
+ word-break: break-all;
+ padding-top: 4px;
+
+ > .name {
+ display: block;
+ margin: 0;
+ padding: 0;
+ font-size: 0.9em;
+ font-weight: bold;
+ word-break: break-word;
+
+ > .ext {
+ opacity: 0.5;
+ }
+ }
+
+ > .tags {
+ display: block;
+ margin: 4px 0 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 0.5em;
+
+ > .tag {
+ display: inline-block;
+ margin: 0 5px 0 0;
+ padding: 1px 5px;
+ border-radius: 2px;
+ }
+ }
+
+ > footer {
+ display: block;
+ margin: 4px 0 0 0;
+ font-size: 0.7em;
+
+ > .separator {
+ padding: 0 4px;
+ }
+
+ > .type {
+ opacity: 0.7;
+
+ > .icon {
+ margin-right: 4px;
+ }
+ }
+
+ > .data-size {
+ opacity: 0.7;
+ }
+
+ > .created-at {
+ opacity: 0.7;
+
+ > [data-icon] {
+ margin-right: 2px;
+ }
+ }
+
+ > .nsfw {
+ color: #bf4633;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue
new file mode 100644
index 0000000000..6b63da742c
--- /dev/null
+++ b/src/client/pages/settings/general.vue
@@ -0,0 +1,108 @@
+<template>
+<section class="mk-settings-page-general _section">
+ <div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
+ <div class="_content">
+ <mk-input type="file" @change="onWallpaperChange" style="margin-top: 0;">
+ <span>{{ $t('wallpaper') }}</span>
+ <template #icon><fa :icon="faImage"/></template>
+ <template #desc v-if="wallpaperUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
+ </mk-input>
+ <mk-button primary :disabled="$store.state.settings.wallpaper == null" @click="delWallpaper()">{{ $t('removeWallpaper') }}</mk-button>
+ </div>
+ <div class="_content">
+ <mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
+ {{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template>
+ </mk-switch>
+ </div>
+ <div class="_content">
+ <mk-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</mk-button>
+ <mk-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</mk-button>
+ <mk-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faImage, faCog } from '@fortawesome/free-solid-svg-icons';
+import MkInput from '../../components/ui/input.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import i18n from '../../i18n';
+import { apiUrl } from '../../config';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkInput,
+ MkButton,
+ MkSwitch,
+ },
+
+ data() {
+ return {
+ wallpaperUploading: false,
+ faImage, faCog
+ }
+ },
+
+ computed: {
+ wallpaper: {
+ get() { return this.$store.state.settings.wallpaper; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'wallpaper', value }); }
+ },
+ },
+
+ methods: {
+ onWallpaperChange([file]) {
+ this.wallpaperUploading = true;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.wallpaper = f.url;
+ this.wallpaperUploading = false;
+ document.documentElement.style.backgroundImage = `url(${this.$store.state.settings.wallpaper})`;
+ })
+ .catch(e => {
+ this.wallpaperUploading = false;
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ delWallpaper() {
+ this.wallpaper = null;
+ document.documentElement.style.backgroundImage = 'none';
+ },
+
+ onChangeAutoWatch(v) {
+ this.$root.api('i/update', {
+ autoWatch: v
+ });
+ },
+
+ readAllUnreadNotes() {
+ this.$root.api('i/read_all_unread_notes');
+ },
+
+ readAllMessagingMessages() {
+ this.$root.api('i/read_all_messaging_messages');
+ },
+
+ readAllNotifications() {
+ this.$root.api('notifications/mark_all_as_read');
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/import-export.vue b/src/client/pages/settings/import-export.vue
new file mode 100644
index 0000000000..5714aabbf6
--- /dev/null
+++ b/src/client/pages/settings/import-export.vue
@@ -0,0 +1,121 @@
+<template>
+<section class="mk-settings-page-import-export _section">
+ <div class="_title"><fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div>
+ <div class="_content">
+ <input ref="file" type="file" style="display: none;" @change="onChangeFile"/>
+ <mk-select v-model="exportTarget" style="margin-top: 0;">
+ <option value="notes">{{ $t('_exportOrImport.allNotes') }}</option>
+ <option value="following">{{ $t('_exportOrImport.followingList') }}</option>
+ <option value="user-lists">{{ $t('_exportOrImport.userLists') }}</option>
+ <option value="mute">{{ $t('_exportOrImport.muteList') }}</option>
+ <option value="blocking">{{ $t('_exportOrImport.blockingList') }}</option>
+ </mk-select>
+ <mk-button inline @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</mk-button>
+ <mk-button inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faDownload, faUpload, faBoxes } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkSelect from '../../components/ui/select.vue';
+import i18n from '../../i18n';
+import { apiUrl } from '../../config';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ MkSelect,
+ },
+
+ data() {
+ return {
+ exportTarget: 'notes',
+ faDownload, faUpload, faBoxes
+ }
+ },
+
+ methods: {
+ doExport() {
+ this.$root.api(
+ this.exportTarget == 'notes' ? 'i/export-notes' :
+ this.exportTarget == 'following' ? 'i/export-following' :
+ this.exportTarget == 'blocking' ? 'i/export-blocking' :
+ this.exportTarget == 'user-lists' ? 'i/export-user-lists' :
+ null, {})
+ .then(() => {
+ this.$root.dialog({
+ type: 'info',
+ text: this.$t('exportRequested')
+ });
+ }).catch((e: any) => {
+ this.$root.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+
+ doImport() {
+ (this.$refs.file as any).click();
+ },
+
+ onChangeFile() {
+ const [file] = Array.from((this.$refs.file as any).files);
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ text: this.$t('uploading') + '...',
+ showOkButton: false,
+ showCancelButton: false,
+ cancelableByBgClick: false
+ });
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.reqImport(f);
+ })
+ .catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ })
+ .finally(() => {
+ dialog.close();
+ });
+ },
+
+ reqImport(file) {
+ this.$root.api(
+ this.exportTarget == 'following' ? 'i/import-following' :
+ this.exportTarget == 'user-lists' ? 'i/import-user-lists' :
+ null, {
+ fileId: file.id
+ }).then(() => {
+ this.$root.dialog({
+ type: 'info',
+ text: this.$t('importRequested')
+ });
+ }).catch((e: any) => {
+ this.$root.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue
new file mode 100644
index 0000000000..1a00c65760
--- /dev/null
+++ b/src/client/pages/settings/index.vue
@@ -0,0 +1,94 @@
+<template>
+<div class="mk-settings-page">
+ <portal to="icon"><fa :icon="faCog"/></portal>
+ <portal to="title">{{ $t('settings') }}</portal>
+
+ <x-profile-setting/>
+ <x-privacy-setting/>
+ <x-reaction-setting/>
+ <x-theme/>
+ <x-import-export/>
+ <x-drive/>
+ <x-general/>
+ <x-mute-block/>
+ <x-security/>
+ <x-2fa/>
+ <x-integration/>
+
+ <mk-button @click="cacheClear()" primary class="cacheClear">{{ $t('cacheClear') }}</mk-button>
+ <mk-button @click="$root.signout()" primary class="logout">{{ $t('logout') }}</mk-button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCog } from '@fortawesome/free-solid-svg-icons';
+import XProfileSetting from './profile.vue';
+import XPrivacySetting from './privacy.vue';
+import XImportExport from './import-export.vue';
+import XDrive from './drive.vue';
+import XGeneral from './general.vue';
+import XReactionSetting from './reaction.vue';
+import XMuteBlock from './mute-block.vue';
+import XSecurity from './security.vue';
+import XTheme from './theme.vue';
+import X2fa from './2fa.vue';
+import XIntegration from './integration.vue';
+import MkButton from '../../components/ui/button.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: this.$t('settings') as string
+ };
+ },
+
+ components: {
+ XProfileSetting,
+ XPrivacySetting,
+ XImportExport,
+ XDrive,
+ XGeneral,
+ XReactionSetting,
+ XMuteBlock,
+ XSecurity,
+ XTheme,
+ X2fa,
+ XIntegration,
+ MkButton,
+ },
+
+ data() {
+ return {
+ faCog
+ }
+ },
+
+ methods: {
+ cacheClear() {
+ // Clear cache (service worker)
+ try {
+ navigator.serviceWorker.controller.postMessage('clear');
+
+ navigator.serviceWorker.getRegistrations().then(registrations => {
+ for (const registration of registrations) registration.unregister();
+ });
+ } catch (e) {
+ console.error(e);
+ }
+
+ // Force reload
+ location.reload(true);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-settings-page {
+ > .logout,
+ > .cacheClear {
+ margin: 8px auto;
+ }
+}
+</style>
diff --git a/src/client/pages/settings/integration.vue b/src/client/pages/settings/integration.vue
new file mode 100644
index 0000000000..b156e13027
--- /dev/null
+++ b/src/client/pages/settings/integration.vue
@@ -0,0 +1,122 @@
+<template>
+<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration">
+ <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
+ <div class="_content" v-if="enableTwitterIntegration">
+ <header><fa :icon="faTwitter"/> Twitter</header>
+ <p v-if="$store.state.i.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p>
+ <mk-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</mk-button>
+ <mk-button v-else @click="connectTwitter">{{ $t('connectSerice') }}</mk-button>
+ </div>
+
+ <div class="_content" v-if="enableDiscordIntegration">
+ <header><fa :icon="faDiscord"/> Discord</header>
+ <p v-if="$store.state.i.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p>
+ <mk-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</mk-button>
+ <mk-button v-else @click="connectDiscord">{{ $t('connectSerice') }}</mk-button>
+ </div>
+
+ <div class="_content" v-if="enableGithubIntegration">
+ <header><fa :icon="faGithub"/> GitHub</header>
+ <p v-if="$store.state.i.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.github.login }}</a></p>
+ <mk-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</mk-button>
+ <mk-button v-else @click="connectGithub">{{ $t('connectSerice') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faShareAlt } from '@fortawesome/free-solid-svg-icons';
+import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
+import i18n from '../../i18n';
+import { apiUrl } from '../../config';
+import MkButton from '../../components/ui/button.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ apiUrl,
+ twitterForm: null,
+ discordForm: null,
+ githubForm: null,
+ enableTwitterIntegration: false,
+ enableDiscordIntegration: false,
+ enableGithubIntegration: false,
+ faShareAlt, faTwitter, faDiscord, faGithub
+ };
+ },
+
+ created() {
+ this.$root.getMeta().then(meta => {
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ });
+ },
+
+ mounted() {
+ if (!document.cookie.match(/i=(\w+)/)) {
+ document.cookie = `i=${this.$store.state.i.token}; path=/;` +
+ ` domain=${document.location.hostname}; max-age=31536000;` +
+ (document.location.protocol.startsWith('https') ? ' secure' : '');
+ }
+ this.$watch('$store.state.i', () => {
+ if (this.$store.state.i.twitter) {
+ if (this.twitterForm) this.twitterForm.close();
+ }
+ if (this.$store.state.i.discord) {
+ if (this.discordForm) this.discordForm.close();
+ }
+ if (this.$store.state.i.github) {
+ if (this.githubForm) this.githubForm.close();
+ }
+ }, {
+ deep: true
+ });
+ },
+
+ methods: {
+ connectTwitter() {
+ this.twitterForm = window.open(apiUrl + '/connect/twitter',
+ 'twitter_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectTwitter() {
+ window.open(apiUrl + '/disconnect/twitter',
+ 'twitter_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectDiscord() {
+ this.discordForm = window.open(apiUrl + '/connect/discord',
+ 'discord_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectDiscord() {
+ window.open(apiUrl + '/disconnect/discord',
+ 'discord_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectGithub() {
+ this.githubForm = window.open(apiUrl + '/connect/github',
+ 'github_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectGithub() {
+ window.open(apiUrl + '/disconnect/github',
+ 'github_disconnect_window',
+ 'height=570, width=520');
+ },
+ }
+});
+</script>
diff --git a/src/client/pages/settings/mute-block.vue b/src/client/pages/settings/mute-block.vue
new file mode 100644
index 0000000000..109b33d4f5
--- /dev/null
+++ b/src/client/pages/settings/mute-block.vue
@@ -0,0 +1,76 @@
+<template>
+<section class="mk-settings-page-mute-block _section">
+ <div class="_title"><fa :icon="faBan"/> {{ $t('muteAndBlock') }}</div>
+ <div class="_content">
+ <span>{{ $t('mutedUsers') }}</span>
+ <mk-pagination :pagination="mutingPagination" class="muting">
+ <template #empty><span>{{ $t('noUsers') }}</span></template>
+ <template #default="{items}">
+ <div class="user" v-for="(mute, i) in items" :key="mute.id" :data-index="i">
+ <router-link class="name" :to="mute.mutee | userPage">
+ <mk-acct :user="mute.mutee"/>
+ </router-link>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+ <div class="_content">
+ <span>{{ $t('blockedUsers') }}</span>
+ <mk-pagination :pagination="blockingPagination" class="blocking">
+ <template #empty><span>{{ $t('noUsers') }}</span></template>
+ <template #default="{items}">
+ <div class="user" v-for="(block, i) in items" :key="block.id" :data-index="i">
+ <router-link class="name" :to="block.blockee | userPage">
+ <mk-acct :user="block.blockee"/>
+ </router-link>
+ </div>
+ </template>
+ </mk-pagination>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faBan } from '@fortawesome/free-solid-svg-icons';
+import MkPagination from '../../components/ui/pagination.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkPagination,
+ },
+
+ data() {
+ return {
+ mutingPagination: {
+ endpoint: 'mute/list',
+ limit: 10,
+ },
+ blockingPagination: {
+ endpoint: 'blocking/list',
+ limit: 10,
+ },
+ faBan
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-settings-page-mute-block {
+ > ._content {
+ max-height: 350px;
+ overflow: auto;
+
+ > .muting,
+ > .blocking {
+ > .empty {
+ opacity: 0.5 !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue
new file mode 100644
index 0000000000..0fc67d5b7d
--- /dev/null
+++ b/src/client/pages/settings/privacy.vue
@@ -0,0 +1,69 @@
+<template>
+<section class="mk-settings-page-privacy _section">
+ <div class="_title"><fa :icon="faLock"/> {{ $t('privacy') }}</div>
+ <div class="_content">
+ <mk-switch v-model="isLocked" @change="save()">{{ $t('makeFollowManuallyApprove') }}</mk-switch>
+ <mk-switch v-model="autoAcceptFollowed" :disabled="!isLocked" @change="save()">{{ $t('autoAcceptFollowed') }}</mk-switch>
+ </div>
+ <div class="_content">
+ <mk-select v-model="defaultNoteVisibility" style="margin-top: 8px;">
+ <template #label>{{ $t('defaultNoteVisibility') }}</template>
+ <option value="public">{{ $t('_visibility.public') }}</option>
+ <option value="followers">{{ $t('_visibility.followers') }}</option>
+ <option value="specified">{{ $t('_visibility.specified') }}</option>
+ </mk-select>
+ <mk-switch v-model="rememberNoteVisibility" @change="save()">{{ $t('rememberNoteVisibility') }}</mk-switch>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import MkSelect from '../../components/ui/select.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkSelect,
+ MkSwitch,
+ },
+
+ data() {
+ return {
+ isLocked: false,
+ autoAcceptFollowed: false,
+ faLock
+ }
+ },
+
+ computed: {
+ defaultNoteVisibility: {
+ get() { return this.$store.state.settings.defaultNoteVisibility; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); }
+ },
+
+ rememberNoteVisibility: {
+ get() { return this.$store.state.settings.rememberNoteVisibility; },
+ set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); }
+ },
+ },
+
+ created() {
+ this.isLocked = this.$store.state.i.isLocked;
+ this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed;
+ },
+
+ methods: {
+ save() {
+ this.$root.api('i/update', {
+ isLocked: !!this.isLocked,
+ autoAcceptFollowed: !!this.autoAcceptFollowed,
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue
new file mode 100644
index 0000000000..e6219c2d56
--- /dev/null
+++ b/src/client/pages/settings/profile.vue
@@ -0,0 +1,246 @@
+<template>
+<section class="mk-settings-page-profile _section">
+ <div class="_title"><fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div>
+ <div class="_content">
+ <mk-input v-model="name" :max="30">
+ <span>{{ $t('_profile.name') }}</span>
+ </mk-input>
+
+ <mk-textarea v-model="description" :max="500">
+ <span>{{ $t('_profile.description') }}</span>
+ <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template>
+ </mk-textarea>
+
+ <mk-input v-model="location">
+ <span>{{ $t('location') }}</span>
+ <template #prefix><fa :icon="faMapMarkerAlt"/></template>
+ </mk-input>
+
+ <mk-input v-model="birthday" type="date">
+ <template #title>{{ $t('birthday') }}</template>
+ <template #prefix><fa :icon="faBirthdayCake"/></template>
+ </mk-input>
+
+ <mk-input type="file" @change="onAvatarChange">
+ <span>{{ $t('avatar') }}</span>
+ <template #icon><fa :icon="faImage"/></template>
+ <template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
+ </mk-input>
+
+ <mk-input type="file" @change="onBannerChange">
+ <span>{{ $t('banner') }}</span>
+ <template #icon><fa :icon="faImage"/></template>
+ <template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
+ </mk-input>
+
+ <details class="fields">
+ <summary>{{ $t('_profile.metadata') }}</summary>
+ <div class="row">
+ <mk-input v-model="fieldName0">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue0">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ <div class="row">
+ <mk-input v-model="fieldName1">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue1">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ <div class="row">
+ <mk-input v-model="fieldName2">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue2">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ <div class="row">
+ <mk-input v-model="fieldName3">{{ $t('_profile.metadataLabel') }}</mk-input>
+ <mk-input v-model="fieldValue3">{{ $t('_profile.metadataContent') }}</mk-input>
+ </div>
+ </details>
+
+ <mk-switch v-model="isBot">{{ $t('flagAsBot') }}</mk-switch>
+ <mk-switch v-model="isCat">{{ $t('flagAsCat') }}</mk-switch>
+ </div>
+ <div class="_footer">
+ <mk-button @click="save(true)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons';
+import { faSave } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkInput from '../../components/ui/input.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import i18n from '../../i18n';
+import { apiUrl, host } from '../../config';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkSwitch,
+ },
+
+ data() {
+ return {
+ host,
+ name: null,
+ description: null,
+ birthday: null,
+ location: null,
+ fieldName0: null,
+ fieldValue0: null,
+ fieldName1: null,
+ fieldValue1: null,
+ fieldName2: null,
+ fieldValue2: null,
+ fieldName3: null,
+ fieldValue3: null,
+ avatarId: null,
+ bannerId: null,
+ isBot: false,
+ isCat: false,
+ saving: false,
+ avatarUploading: false,
+ bannerUploading: false,
+ faSave, faUnlockAlt, faCogs, faImage, faUser, faMapMarkerAlt, faBirthdayCake
+ }
+ },
+
+ created() {
+ this.name = this.$store.state.i.name;
+ this.description = this.$store.state.i.description;
+ this.location = this.$store.state.i.location;
+ this.birthday = this.$store.state.i.birthday;
+ this.avatarId = this.$store.state.i.avatarId;
+ this.bannerId = this.$store.state.i.bannerId;
+ this.isBot = this.$store.state.i.isBot;
+ this.isCat = this.$store.state.i.isCat;
+
+ this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null;
+ this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null;
+ this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null;
+ this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null;
+ this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null;
+ this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null;
+ this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null;
+ this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null;
+ },
+
+ methods: {
+ onAvatarChange([file]) {
+ this.avatarUploading = true;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.avatarId = f.id;
+ this.avatarUploading = false;
+ })
+ .catch(e => {
+ this.avatarUploading = false;
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ onBannerChange([file]) {
+ this.bannerUploading = true;
+
+ const data = new FormData();
+ data.append('file', file);
+ data.append('i', this.$store.state.i.token);
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ this.bannerId = f.id;
+ this.bannerUploading = false;
+ })
+ .catch(e => {
+ this.bannerUploading = false;
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ save(notify) {
+ const fields = [
+ { name: this.fieldName0, value: this.fieldValue0 },
+ { name: this.fieldName1, value: this.fieldValue1 },
+ { name: this.fieldName2, value: this.fieldValue2 },
+ { name: this.fieldName3, value: this.fieldValue3 },
+ ];
+
+ this.saving = true;
+
+ this.$root.api('i/update', {
+ name: this.name || null,
+ description: this.description || null,
+ location: this.location || null,
+ birthday: this.birthday || null,
+ avatarId: this.avatarId || undefined,
+ bannerId: this.bannerId || undefined,
+ fields,
+ isBot: !!this.isBot,
+ isCat: !!this.isCat,
+ }).then(i => {
+ this.saving = false;
+ this.$store.state.i.avatarId = i.avatarId;
+ this.$store.state.i.avatarUrl = i.avatarUrl;
+ this.$store.state.i.bannerId = i.bannerId;
+ this.$store.state.i.bannerUrl = i.bannerUrl;
+
+ if (notify) {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }
+ }).catch(err => {
+ this.saving = false;
+ this.$root.dialog({
+ type: 'error',
+ text: err.id
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-settings-page-profile {
+ > ._content {
+ > *:first-child {
+ margin-top: 0;
+ }
+
+ > .fields {
+ > .row {
+ > * {
+ display: inline-block;
+ width: 50%;
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue
new file mode 100644
index 0000000000..310237b5fd
--- /dev/null
+++ b/src/client/pages/settings/reaction.vue
@@ -0,0 +1,62 @@
+<template>
+<section class="mk-settings-page-reaction _section">
+ <div class="_title"><fa :icon="faLaugh"/> {{ $t('reaction') }}</div>
+ <div class="_content">
+ <mk-textarea v-model="reactions" style="margin-top: 16px;">{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }}</template></mk-textarea>
+ </div>
+ <div class="_footer">
+ <mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+ <mk-button inline @click="preview"><fa :icon="faEye"/> {{ $t('preview') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkReactionPicker from '../../components/reaction-picker.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkTextarea,
+ MkButton,
+ },
+
+ data() {
+ return {
+ reactions: this.$store.state.settings.reactions.join('\n'),
+ changed: false,
+ faLaugh, faSave, faEye
+ }
+ },
+
+ watch: {
+ reactions() {
+ this.changed = true;
+ }
+ },
+
+ methods: {
+ save() {
+ this.$store.dispatch('settings/set', { key: 'reactions', value: this.reactions.trim().split('\n') });
+ this.changed = false;
+ },
+
+ preview(ev) {
+ const picker = this.$root.new(MkReactionPicker, {
+ source: ev.currentTarget || ev.target,
+ reactions: this.reactions.trim().split('\n'),
+ showFocus: false,
+ });
+ picker.$once('chosen', reaction => {
+ picker.close();
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue
new file mode 100644
index 0000000000..ecf9c01dd5
--- /dev/null
+++ b/src/client/pages/settings/security.vue
@@ -0,0 +1,87 @@
+<template>
+<section class="_section">
+ <div class="_title"><fa :icon="faLock"/> {{ $t('password') }}</div>
+ <div class="_content">
+ <mk-button primary @click="change()">{{ $t('changePassword') }}</mk-button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faLock } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import i18n from '../../i18n';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkButton,
+ },
+
+ data() {
+ return {
+ faLock
+ }
+ },
+
+ methods: {
+ async change() {
+ const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({
+ title: this.$t('currentPassword'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: newPassword } = await this.$root.dialog({
+ title: this.$t('newPassword'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled2) return;
+
+ const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({
+ title: this.$t('newPasswordRetype'),
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled3) return;
+
+ if (newPassword !== newPassword2) {
+ this.$root.dialog({
+ type: 'error',
+ text: this.$t('retypedNotMatch')
+ });
+ return;
+ }
+
+ const dialog = this.$root.dialog({
+ type: 'waiting',
+ iconOnly: true
+ });
+
+ this.$root.api('i/change-password', {
+ currentPassword,
+ newPassword
+ }).then(() => {
+ this.$root.dialog({
+ type: 'success',
+ iconOnly: true, autoClose: true
+ });
+ }).catch(e => {
+ this.$root.dialog({
+ type: 'error',
+ text: e
+ });
+ }).finally(() => {
+ dialog.close();
+ });
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue
new file mode 100644
index 0000000000..71628ab2b9
--- /dev/null
+++ b/src/client/pages/settings/theme.vue
@@ -0,0 +1,76 @@
+<template>
+<section class="mk-settings-page-theme _section">
+ <div class="_title"><fa :icon="faPalette"/> {{ $t('theme') }}</div>
+ <div class="_content">
+ <mk-select v-model="theme" :placeholder="$t('theme')">
+ <template #label>{{ $t('theme') }}</template>
+ <optgroup :label="$t('lightThemes')">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$t('darkThemes')">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </mk-select>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faPalette } from '@fortawesome/free-solid-svg-icons';
+import MkInput from '../../components/ui/input.vue';
+import MkButton from '../../components/ui/button.vue';
+import MkSelect from '../../components/ui/select.vue';
+import i18n from '../../i18n';
+import { Theme, builtinThemes, applyTheme } from '../../theme';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkInput,
+ MkButton,
+ MkSelect,
+ },
+
+ data() {
+ return {
+ wallpaperUploading: false,
+ faPalette
+ }
+ },
+
+ computed: {
+ themes(): Theme[] {
+ return builtinThemes.concat(this.$store.state.device.themes);
+ },
+
+ installedThemes(): Theme[] {
+ return this.$store.state.device.themes;
+ },
+
+ darkThemes(): Theme[] {
+ return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark');
+ },
+
+ lightThemes(): Theme[] {
+ return this.themes.filter(t => t.base == 'light' || t.kind == 'light');
+ },
+
+ theme: {
+ get() { return this.$store.state.device.theme; },
+ set(value) { this.$store.commit('device/set', { key: 'theme', value }); }
+ },
+ },
+
+ watch: {
+ theme() {
+ applyTheme(this.themes.find(x => x.id === this.theme));
+ }
+ },
+
+ methods: {
+
+ }
+});
+</script>
diff --git a/src/client/pages/tag.vue b/src/client/pages/tag.vue
new file mode 100644
index 0000000000..f53f3c5ca1
--- /dev/null
+++ b/src/client/pages/tag.vue
@@ -0,0 +1,49 @@
+<template>
+<x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import Progress from '../scripts/loading';
+import XNotes from '../components/notes.vue';
+
+export default Vue.extend({
+ metaInfo() {
+ return {
+ title: '#' + this.$route.params.tag
+ };
+ },
+
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'notes/search-by-tag',
+ limit: 10,
+ params: () => ({
+ tag: this.$route.params.tag,
+ })
+ }
+ };
+ },
+
+ watch: {
+ $route() {
+ (this.$refs.notes as any).reload();
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/src/client/pages/user/follow-list.vue b/src/client/pages/user/follow-list.vue
new file mode 100644
index 0000000000..faaee3b107
--- /dev/null
+++ b/src/client/pages/user/follow-list.vue
@@ -0,0 +1,140 @@
+<template>
+<mk-pagination :pagination="pagination" #default="{items}" class="mk-following-or-followers" ref="list">
+ <div class="user _panel" v-for="(user, i) in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" :data-index="i">
+ <mk-avatar class="avatar" :user="user"/>
+ <div class="body">
+ <div class="name">
+ <router-link class="name" :to="user | userPage" v-user-preview="user.id"><mk-user-name :user="user"/></router-link>
+ <p class="acct">@{{ user | acct }}</p>
+ </div>
+ <div class="description" v-if="user.description" :title="user.description">
+ <mfm :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :plain="true" :nowrap="true"/>
+ </div>
+ <x-follow-button class="koudoku-button" v-if="$store.getters.isSignedIn && user.id != $store.state.i.id" :user="user" mini/>
+ </div>
+ </div>
+</mk-pagination>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import parseAcct from '../../../misc/acct/parse';
+import i18n from '../../i18n';
+import XFollowButton from '../../components/follow-button.vue';
+import MkPagination from '../../components/ui/pagination.vue';
+
+export default Vue.extend({
+ i18n,
+
+ components: {
+ MkPagination,
+ XFollowButton,
+ },
+
+ props: {
+ type: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers',
+ limit: 20,
+ params: {
+ ...parseAcct(this.$route.params.user),
+ }
+ },
+ };
+ },
+
+ watch: {
+ type() {
+ this.$refs.list.reload();
+ },
+
+ '$route'() {
+ this.$refs.list.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-following-or-followers {
+ > .user {
+ display: flex;
+ padding: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 42px;
+ height: 42px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ display: flex;
+ width: calc(100% - 54px);
+ position: relative;
+
+ > .name {
+ width: 45%;
+
+ @media (max-width: 500px) {
+ width: 100%;
+ }
+
+ > .name,
+ > .acct {
+ display: block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ margin: 0;
+ }
+
+ > .name {
+ font-size: 16px;
+ line-height: 24px;
+ }
+
+ > .acct {
+ font-size: 15px;
+ line-height: 16px;
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ width: 55%;
+ line-height: 42px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 0.7;
+ font-size: 14px;
+ padding-right: 40px;
+ padding-left: 8px;
+ box-sizing: border-box;
+
+ @media (max-width: 500px) {
+ display: none;
+ }
+ }
+
+ > .koudoku-button {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ margin: auto 0;
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/user/index.activity.vue b/src/client/pages/user/index.activity.vue
new file mode 100644
index 0000000000..29dcca0664
--- /dev/null
+++ b/src/client/pages/user/index.activity.vue
@@ -0,0 +1,114 @@
+<template>
+<div>
+ <div ref="chart"></div>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import ApexCharts from 'apexcharts';
+
+export default Vue.extend({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ limit: {
+ type: Number,
+ required: false,
+ default: 40
+ }
+ },
+ data() {
+ return {
+ fetching: true,
+ data: [],
+ peak: null
+ };
+ },
+ mounted() {
+ this.$root.api('charts/user/notes', {
+ userId: this.user.id,
+ span: 'day',
+ limit: this.limit
+ }).then(stats => {
+ const normal = [];
+ const reply = [];
+ const renote = [];
+
+ const now = new Date();
+ const y = now.getFullYear();
+ const m = now.getMonth();
+ const d = now.getDate();
+
+ for (let i = 0; i < this.limit; i++) {
+ const x = new Date(y, m, d - i);
+ normal.push([
+ x,
+ stats.diffs.normal[i]
+ ]);
+ reply.push([
+ x,
+ stats.diffs.reply[i]
+ ]);
+ renote.push([
+ x,
+ stats.diffs.renote[i]
+ ]);
+ }
+
+ const chart = new ApexCharts(this.$refs.chart, {
+ chart: {
+ type: 'bar',
+ stacked: true,
+ height: 100,
+ sparkline: {
+ enabled: true
+ },
+ },
+ plotOptions: {
+ bar: {
+ columnWidth: '40%'
+ }
+ },
+ dataLabels: {
+ enabled: false
+ },
+ grid: {
+ clipMarkers: false,
+ padding: {
+ top: 0,
+ right: 8,
+ bottom: 0,
+ left: 8
+ }
+ },
+ tooltip: {
+ shared: true,
+ intersect: false
+ },
+ series: [{
+ name: 'Normal',
+ data: normal
+ }, {
+ name: 'Reply',
+ data: reply
+ }, {
+ name: 'Renote',
+ data: renote
+ }],
+ xaxis: {
+ type: 'datetime',
+ crosshairs: {
+ width: 1,
+ opacity: 1
+ }
+ }
+ });
+
+ chart.render();
+ });
+ }
+});
+</script>
diff --git a/src/client/pages/user/index.photos.vue b/src/client/pages/user/index.photos.vue
new file mode 100644
index 0000000000..cd29254f48
--- /dev/null
+++ b/src/client/pages/user/index.photos.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="ujigsodd">
+ <mk-loading v-if="fetching"/>
+ <div class="stream" v-if="!fetching && images.length > 0">
+ <a v-for="(image, i) in images" :key="i"
+ class="img"
+ :style="`background-image: url(${thumbnail(image.file)})`"
+ :href="image.note | notePage"
+ ></a>
+ </div>
+ <p class="empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import i18n from '../../i18n';
+import { getStaticImageUrl } from '../../scripts/get-static-image-url';
+
+export default Vue.extend({
+ i18n,
+ props: ['user'],
+ data() {
+ return {
+ fetching: true,
+ images: []
+ };
+ },
+ mounted() {
+ const image = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/apng',
+ 'image/vnd.mozilla.apng',
+ ];
+ this.$root.api('users/notes', {
+ userId: this.user.id,
+ fileType: image,
+ excludeNsfw: !this.$store.state.device.alwaysShowNsfw,
+ limit: 9,
+ }).then(notes => {
+ for (const note of notes) {
+ for (const file of note.files) {
+ if (this.images.length < 9) {
+ this.images.push({
+ note,
+ file
+ });
+ }
+ }
+ }
+ this.fetching = false;
+ });
+ },
+ methods: {
+ thumbnail(image: any): string {
+ return this.$store.state.device.disableShowingAnimatedImages
+ ? getStaticImageUrl(image.thumbnailUrl)
+ : image.thumbnailUrl;
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ujigsodd {
+
+ > .stream {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ padding: 8px;
+
+ > .img {
+ flex: 1 1 33%;
+ width: 33%;
+ height: 90px;
+ box-sizing: border-box;
+ background-position: center center;
+ background-size: cover;
+ background-clip: content-box;
+ border: solid 2px transparent;
+ border-radius: 4px;
+ }
+ }
+
+ > .empty {
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue
new file mode 100644
index 0000000000..1878a9b1f3
--- /dev/null
+++ b/src/client/pages/user/index.timeline.vue
@@ -0,0 +1,79 @@
+<template>
+<div class="kjeftjfm">
+ <div class="with">
+ <button class="_button" @click="with_ = null" :class="{ active: with_ === null }">{{ $t('notes') }}</button>
+ <button class="_button" @click="with_ = 'replies'" :class="{ active: with_ === 'replies' }">{{ $t('notesAndReplies') }}</button>
+ <button class="_button" @click="with_ = 'files'" :class="{ active: with_ === 'files' }">{{ $t('withFiles') }}</button>
+ </div>
+ <x-notes ref="timeline" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import XNotes from '../../components/notes.vue';
+
+export default Vue.extend({
+ components: {
+ XNotes
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ watch: {
+ user() {
+ this.$refs.timeline.reload();
+ },
+
+ with_() {
+ this.$refs.timeline.reload();
+ },
+ },
+
+ data() {
+ return {
+ date: null,
+ with_: null,
+ pagination: {
+ endpoint: 'users/notes',
+ limit: 10,
+ params: init => ({
+ userId: this.user.id,
+ includeReplies: this.with_ === 'replies',
+ withFiles: this.with_ === 'files',
+ untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+ })
+ }
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.kjeftjfm {
+ > .with {
+ display: flex;
+ margin-bottom: var(--margin);
+
+ @media (max-width: 500px) {
+ font-size: 80%;
+ }
+
+ > button {
+ flex: 1;
+ padding: 11px 8px 8px 8px;
+ border-bottom: solid 3px transparent;
+
+ &.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ }
+ }
+ }
+}
+</style>
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
new file mode 100644
index 0000000000..7bf45621b3
--- /dev/null
+++ b/src/client/pages/user/index.vue
@@ -0,0 +1,476 @@
+<template>
+<div class="mk-user-page" v-if="user">
+ <portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
+ <portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
+
+ <div class="remote-caution _panel" v-if="user.host != null"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/>{{ $t('remoteUserCaution') }}<a :href="user.url" rel="nofollow noopener" target="_blank">{{ $t('showOnRemote') }}</a></div>
+ <transition name="zoom" mode="out-in" appear>
+ <div class="profile _panel" :key="user.id">
+ <div class="banner-container" :style="style">
+ <div class="banner" ref="banner" :style="style"></div>
+ <div class="fade"></div>
+ <div class="title">
+ <mk-user-name class="name" :user="user" :nowrap="true"/>
+ <div class="bottom">
+ <span class="username"><mk-acct :user="user" :detail="true" /></span>
+ <span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span>
+ <span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span>
+ <span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span>
+ </div>
+ </div>
+ <span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('followsYou') }}</span>
+ <div class="actions" v-if="$store.getters.isSignedIn">
+ <button @click="menu" class="menu _button" ref="menu"><fa :icon="faEllipsisH"/></button>
+ <x-follow-button v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" class="koudoku"/>
+ </div>
+ </div>
+ <mk-avatar class="avatar" :user="user" :disable-preview="true"/>
+ <div class="title">
+ <mk-user-name :user="user" :nowrap="false" class="name"/>
+ <div class="bottom">
+ <span class="username"><mk-acct :user="user" :detail="true" /></span>
+ <span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span>
+ <span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span>
+ <span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span>
+ </div>
+ </div>
+ <div class="description">
+ <mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
+ <p v-else class="empty">{{ $t('noAccountDescription') }}</p>
+ </div>
+ <div class="fields system">
+ <dl class="field" v-if="user.location">
+ <dt class="name"><fa :icon="faMapMarker" fixed-width/> {{ $t('location') }}</dt>
+ <dd class="value">{{ user.location }}</dd>
+ </dl>
+ <dl class="field" v-if="user.birthday">
+ <dt class="name"><fa :icon="faBirthdayCake" fixed-width/> {{ $t('birthday') }}</dt>
+ <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+ </dl>
+ <dl class="field">
+ <dt class="name"><fa :icon="faCalendarAlt" fixed-width/> {{ $t('registeredDate') }}</dt>
+ <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<mk-time :time="user.createdAt"/>)</dd>
+ </dl>
+ </div>
+ <div class="fields" v-if="user.fields.length > 0">
+ <dl class="field" v-for="(field, i) in user.fields" :key="i">
+ <dt class="name">
+ <mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+ </dt>
+ <dd class="value">
+ <mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/>
+ </dd>
+ </dl>
+ </div>
+ <div class="status" v-if="user.host === null">
+ <router-link :to="user | userPage()" :class="{ active: $route.name === 'user' }">
+ <b>{{ user.notesCount | number }}</b>
+ <span>{{ $t('notes') }}</span>
+ </router-link>
+ <router-link :to="user | userPage('following')" :class="{ active: $route.name === 'userFollowing' }">
+ <b>{{ user.followingCount | number }}</b>
+ <span>{{ $t('following') }}</span>
+ </router-link>
+ <router-link :to="user | userPage('followers')" :class="{ active: $route.name === 'userFollowers' }">
+ <b>{{ user.followersCount | number }}</b>
+ <span>{{ $t('followers') }}</span>
+ </router-link>
+ </div>
+ </div>
+ </transition>
+ <router-view :user="user"></router-view>
+ <template v-if="$route.name == 'user'">
+ <sequential-entrance class="pins">
+ <x-note v-for="(note, i) in user.pinnedNotes" class="note" :note="note" :key="note.id" :data-index="i" :detail="true" :pinned="true"/>
+ </sequential-entrance>
+ <mk-container :body-togglable="true" class="content">
+ <template #header><fa :icon="faImage"/>{{ $t('images') }}</template>
+ <div>
+ <x-photos :user="user" :key="user.id"/>
+ </div>
+ </mk-container>
+ <mk-container :body-togglable="true" class="content">
+ <template #header><fa :icon="faChartBar"/>{{ $t('activity') }}</template>
+ <div style="padding:8px;">
+ <x-activity :user="user" :key="user.id"/>
+ </div>
+ </mk-container>
+ <x-user-timeline :user="user"/>
+ </template>
+</div>
+<div v-else-if="error">
+ <mk-error @retry="fetch()"/>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faEllipsisH, faRobot, faLock, faBookmark, faExclamationTriangle, faChartBar, faImage, faBirthdayCake, faMapMarker } from '@fortawesome/free-solid-svg-icons';
+import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
+import * as age from 's-age';
+import XUserTimeline from './index.timeline.vue';
+import XUserMenu from '../../components/user-menu.vue';
+import XNote from '../../components/note.vue';
+import XFollowButton from '../../components/follow-button.vue';
+import MkContainer from '../../components/ui/container.vue';
+import Progress from '../../scripts/loading';
+import parseAcct from '../../../misc/acct/parse';
+
+export default Vue.extend({
+ components: {
+ XUserTimeline,
+ XNote,
+ XFollowButton,
+ MkContainer,
+ XPhotos: () => import('./index.photos.vue').then(m => m.default),
+ XActivity: () => import('./index.activity.vue').then(m => m.default),
+ },
+
+ metaInfo() {
+ return {
+ title: (this.user ? '@' + Vue.filter('acct')(this.user).replace('@', ' | ') : null) as string
+ };
+ },
+
+ data() {
+ return {
+ user: null,
+ error: null,
+ parallaxAnimationId: null,
+ faEllipsisH, faRobot, faLock, faBookmark, faExclamationTriangle, faChartBar, faImage, faBirthdayCake, faMapMarker, faCalendarAlt
+ };
+ },
+
+ computed: {
+ style(): any {
+ if (this.user.bannerUrl == null) return {};
+ return {
+ backgroundImage: `url(${ this.user.bannerUrl })`
+ };
+ },
+
+ age(): number {
+ return age(this.user.birthday);
+ }
+ },
+
+ watch: {
+ $route: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ mounted() {
+ window.requestAnimationFrame(this.parallaxLoop);
+ window.addEventListener('scroll', this.parallax, { passive: true });
+ document.addEventListener('touchmove', this.parallax, { passive: true });
+ this.$once('hook:beforeDestroy', () => {
+ window.cancelAnimationFrame(this.parallaxAnimationId);
+ window.removeEventListener('scroll', this.parallax);
+ document.removeEventListener('touchmove', this.parallax);
+ });
+ },
+
+ methods: {
+ fetch() {
+ Progress.start();
+ this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
+ this.user = user;
+ }).catch(e => {
+ this.error = e;
+ }).finally(() => {
+ Progress.done();
+ });
+ },
+
+ menu() {
+ this.$root.new(XUserMenu, {
+ source: this.$refs.menu,
+ user: this.user
+ });
+ },
+
+ parallaxLoop() {
+ this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop);
+ this.parallax();
+ },
+
+ parallax() {
+ const banner = this.$refs.banner as any;
+ if (banner == null) return;
+
+ const top = window.scrollY;
+
+ if (top < 0) return;
+
+ const z = 1.75; // 奥行き(小さいほど奥)
+ const pos = -(top / z);
+ banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-user-page {
+ > .remote-caution {
+ font-size: 0.8em;
+ padding: 16px;
+ margin-bottom: var(--margin);
+
+ > a {
+ margin-left: 4px;
+ color: var(--accent);
+ }
+ }
+
+ > .profile {
+ position: relative;
+ margin-bottom: var(--margin);
+ overflow: hidden;
+
+ > .banner-container {
+ position: relative;
+ height: 250px;
+ overflow: hidden;
+ background-size: cover;
+ background-position: center;
+
+ @media (max-width: 500px) {
+ height: 140px;
+ }
+
+ > .banner {
+ height: 100%;
+ background-color: #4c5e6d;
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
+ }
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 78px;
+ background: linear-gradient(transparent, rgba(#000, 0.7));
+
+ @media (max-width: 500px) {
+ display: none;
+ }
+ }
+
+ > .followed {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ padding: 4px 6px;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.7);
+ font-size: 12px;
+ }
+
+ > .actions {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ -webkit-backdrop-filter: blur(8px);
+ backdrop-filter: blur(8px);
+ background: rgba(0, 0, 0, 0.2);
+ padding: 8px;
+ border-radius: 24px;
+
+ > .menu {
+ vertical-align: bottom;
+ height: 31px;
+ width: 31px;
+ color: #fff;
+ text-shadow: 0 0 8px #000;
+ font-size: 16px;
+ }
+
+ > .koudoku {
+ margin-left: 4px;
+ vertical-align: bottom;
+ }
+ }
+
+ > .title {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 0 0 8px 154px;
+ box-sizing: border-box;
+ color: #fff;
+
+ @media (max-width: 500px) {
+ display: none;
+ }
+
+ > .name {
+ display: block;
+ margin: 0;
+ line-height: 32px;
+ font-weight: bold;
+ font-size: 1.8em;
+ text-shadow: 0 0 8px #000;
+ }
+
+ > .bottom {
+ > * {
+ display: inline-block;
+ margin-right: 16px;
+ line-height: 20px;
+ opacity: 0.8;
+
+ &.username {
+ font-weight: bold;
+ }
+ }
+ }
+ }
+ }
+
+ > .title {
+ display: none;
+ text-align: center;
+ padding: 50px 8px 16px 8px;
+ font-weight: bold;
+ border-bottom: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ display: block;
+ }
+
+ > .bottom {
+ > * {
+ display: inline-block;
+ margin-right: 8px;
+ opacity: 0.8;
+ }
+ }
+ }
+
+ > .avatar {
+ display: block;
+ position: absolute;
+ top: 170px;
+ left: 16px;
+ z-index: 2;
+ width: 120px;
+ height: 120px;
+ box-shadow: 1px 1px 3px rgba(#000, 0.2);
+
+ @media (max-width: 500px) {
+ top: 90px;
+ left: 0;
+ right: 0;
+ width: 92px;
+ height: 92px;
+ margin: auto;
+ }
+ }
+
+ > .description {
+ padding: 24px 24px 24px 154px;
+ font-size: 15px;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ text-align: center;
+ }
+
+ > .empty {
+ margin: 0;
+ opacity: 0.5;
+ }
+ }
+
+ > .fields {
+ padding: 24px;
+ font-size: 14px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ }
+
+ > .field {
+ display: flex;
+ padding: 0;
+ margin: 0;
+ align-items: center;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > .name {
+ width: 30%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-weight: bold;
+ text-align: center;
+ }
+
+ > .value {
+ width: 70%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ &.system > .field > .name {
+ }
+ }
+
+ > .status {
+ display: flex;
+ padding: 24px;
+ border-top: solid 1px var(--divider);
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ }
+
+ > a {
+ flex: 1;
+ text-align: center;
+
+ &.active {
+ color: var(--accent);
+ }
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ > b {
+ display: block;
+ line-height: 16px;
+ }
+
+ > span {
+ font-size: 70%;
+ }
+ }
+ }
+ }
+
+ > .pins {
+ > .note {
+ margin-bottom: var(--margin);
+ }
+ }
+
+ > .content {
+ margin-bottom: var(--margin);
+ }
+}
+</style>