diff options
Diffstat (limited to 'src/client/pages')
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> |