From 246693b8484b72048cb515b76aa5f094f5fdeb56 Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Thu, 22 Apr 2021 22:29:33 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=B9=E3=82=BF=E3=83=B3?= =?UTF-8?q?=E3=82=B9=E7=AE=A1=E7=90=86=E7=94=BB=E9=9D=A2=E4=BD=9C=E3=82=8A?= =?UTF-8?q?=E7=9B=B4=E3=81=97=20(#7473)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * wip --- locales/ja-JP.yml | 13 +- package.json | 2 +- src/client/components/captcha.vue | 4 +- src/client/components/form/base.vue | 2 + src/client/components/form/form.scss | 4 +- src/client/components/form/key-value-view.vue | 2 +- src/client/components/form/object-view.vue | 2 +- src/client/components/form/radios.vue | 3 + src/client/components/form/suspense.vue | 9 +- src/client/components/instance-stats.vue | 252 +------- src/client/components/signup.vue | 2 +- src/client/components/tab.vue | 1 + src/client/components/ui/pagination.vue | 34 +- src/client/pages/instance/abuses.vue | 12 +- src/client/pages/instance/announcements.vue | 52 +- src/client/pages/instance/bot-protection.vue | 138 ++++ src/client/pages/instance/database.vue | 60 ++ src/client/pages/instance/email-settings.vue | 127 ++++ src/client/pages/instance/emojis.vue | 213 +++--- src/client/pages/instance/federation.vue | 115 ++-- src/client/pages/instance/file-dialog.vue | 4 +- src/client/pages/instance/files-settings.vue | 92 +++ src/client/pages/instance/files.vue | 8 + src/client/pages/instance/index.vue | 362 ++++++----- src/client/pages/instance/instance-block.vue | 71 ++ .../pages/instance/integrations-discord.vue | 85 +++ .../pages/instance/integrations-github.vue | 85 +++ .../pages/instance/integrations-twitter.vue | 85 +++ src/client/pages/instance/integrations.vue | 73 +++ .../{index.metrics.vue => metrics.vue} | 243 ++----- src/client/pages/instance/object-storage.vue | 154 +++++ src/client/pages/instance/other-settings.vue | 68 ++ src/client/pages/instance/overview.vue | 131 ++++ src/client/pages/instance/proxy-account.vue | 86 +++ src/client/pages/instance/queue.chart.vue | 66 +- src/client/pages/instance/queue.vue | 24 +- src/client/pages/instance/relays.vue | 50 +- src/client/pages/instance/security.vue | 77 +++ src/client/pages/instance/service-worker.vue | 84 +++ src/client/pages/instance/settings.vue | 607 +++--------------- src/client/pages/instance/user-dialog.vue | 230 ------- src/client/pages/instance/user.vue | 229 +++++++ src/client/pages/instance/users.vue | 259 ++++---- src/client/pages/user-info.vue | 70 +- src/client/pages/user/index.vue | 2 +- src/client/router.ts | 12 +- src/client/scripts/get-user-menu.ts | 8 +- src/client/scripts/lookup-user.ts | 37 ++ src/client/ui/_common_/sidebar.vue | 63 +- src/client/ui/default.sidebar.vue | 63 +- 50 files changed, 2588 insertions(+), 1887 deletions(-) create mode 100644 src/client/pages/instance/bot-protection.vue create mode 100644 src/client/pages/instance/database.vue create mode 100644 src/client/pages/instance/email-settings.vue create mode 100644 src/client/pages/instance/files-settings.vue create mode 100644 src/client/pages/instance/instance-block.vue create mode 100644 src/client/pages/instance/integrations-discord.vue create mode 100644 src/client/pages/instance/integrations-github.vue create mode 100644 src/client/pages/instance/integrations-twitter.vue create mode 100644 src/client/pages/instance/integrations.vue rename src/client/pages/instance/{index.metrics.vue => metrics.vue} (60%) create mode 100644 src/client/pages/instance/object-storage.vue create mode 100644 src/client/pages/instance/other-settings.vue create mode 100644 src/client/pages/instance/overview.vue create mode 100644 src/client/pages/instance/proxy-account.vue create mode 100644 src/client/pages/instance/security.vue create mode 100644 src/client/pages/instance/service-worker.vue delete mode 100644 src/client/pages/instance/user-dialog.vue create mode 100644 src/client/pages/instance/user.vue create mode 100644 src/client/scripts/lookup-user.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 989edcda2..b557f8643 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -183,7 +183,7 @@ clearQueueConfirmTitle: "キューをクリアしますか?" clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。" clearCachedFiles: "キャッシュをクリア" clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?" -blockedInstances: "インスタンスブロック" +blockedInstances: "ブロックしたインスタンス" blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" @@ -349,7 +349,6 @@ antennaExcludeKeywords: "除外キーワード" antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" notifyAntenna: "新しいノートを通知する" withFileAntenna: "ファイルが添付されたノートのみ" -serviceworker: "ServiceWorker" enableServiceworker: "ServiceWorkerを有効にする" antennaUsersDescription: "ユーザー名を改行で区切って指定します" caseSensitive: "大文字小文字を区別する" @@ -568,7 +567,7 @@ pluginTokenRequestedDescription: "このプラグインはここで設定した notificationType: "通知の種類" edit: "編集" useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う" -emailConfig: "メールサーバー設定" +emailServer: "メールサーバー" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" email: "メール" @@ -728,6 +727,14 @@ hideOnlineStatusDescription: "オンライン状態を隠すと、検索など online: "オンライン" active: "アクティブ" offline: "オフライン" +notRecommended: "非推奨" +botProtection: "Bot防御" +instanceBlocking: "インスタンスブロック" +selectAccount: "アカウントを選択" +enabled: "有効" +disabled: "無効" +quickAction: "クイックアクション" +user: "ユーザー" _email: _follow: diff --git a/package.json b/package.json index 26aacae49..c6fd1a022 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "misskey", "author": "syuilo <syuilotan@yahoo.co.jp>", - "version": "12.78.0-beta.2", + "version": "12.78.0-beta.3", "codename": "indigo", "repository": { "type": "git", diff --git a/src/client/components/captcha.vue b/src/client/components/captcha.vue index 710fcd616..26215df09 100644 --- a/src/client/components/captcha.vue +++ b/src/client/components/captcha.vue @@ -18,7 +18,7 @@ type Captcha = { getResponse(id: string): string; }; -type CaptchaProvider = 'hcaptcha' | 'grecaptcha'; +type CaptchaProvider = 'hcaptcha' | 'recaptcha'; type CaptchaContainer = { readonly [_ in CaptchaProvider]?: Captcha; @@ -57,7 +57,7 @@ export default defineComponent({ src() { const endpoint = ({ hcaptcha: 'https://hcaptcha.com/1', - grecaptcha: 'https://www.recaptcha.net/recaptcha', + recaptcha: 'https://www.recaptcha.net/recaptcha', } as Record<PropertyKey, unknown>)[this.provider]; return `${typeof endpoint == 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; diff --git a/src/client/components/form/base.vue b/src/client/components/form/base.vue index 34deb3946..132942d52 100644 --- a/src/client/components/form/base.vue +++ b/src/client/components/form/base.vue @@ -24,6 +24,8 @@ export default defineComponent({ --formXPadding: 32px; --formYPadding: 32px; + --formContentHMargin: 16px; + font-size: 95%; line-height: 1.3em; background: var(--bg); diff --git a/src/client/components/form/form.scss b/src/client/components/form/form.scss index 8c01fad72..05994ae65 100644 --- a/src/client/components/form/form.scss +++ b/src/client/components/form/form.scss @@ -30,7 +30,7 @@ top: var(--stickyTop, 0px); z-index: 2; margin: -8px calc(var(--formXPadding) * -1) 0 calc(var(--formXPadding) * -1); - padding: 8px calc(16px + var(--formXPadding)) 8px calc(16px + var(--formXPadding)); + padding: 8px calc(var(--formContentHMargin) + var(--formXPadding)) 8px calc(var(--formContentHMargin) + var(--formXPadding)); background: var(--X17); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); @@ -42,7 +42,7 @@ } ._formCaption { - padding: 8px 16px 0 16px; + padding: 8px var(--formContentHMargin) 0 var(--formContentHMargin); } ._formItem { diff --git a/src/client/components/form/key-value-view.vue b/src/client/components/form/key-value-view.vue index 85f4febef..ca4c09867 100644 --- a/src/client/components/form/key-value-view.vue +++ b/src/client/components/form/key-value-view.vue @@ -20,7 +20,7 @@ export default defineComponent({ .anocepby { display: flex; align-items: center; - padding: 14px 16px; + padding: 14px var(--formContentHMargin); > .key { margin-right: 12px; diff --git a/src/client/components/form/object-view.vue b/src/client/components/form/object-view.vue index cbd4186e5..59fb62b5e 100644 --- a/src/client/components/form/object-view.vue +++ b/src/client/components/form/object-view.vue @@ -75,7 +75,7 @@ export default defineComponent({ max-width: 100%; min-height: 130px; margin: 0; - padding: 16px; + padding: 16px var(--formContentHMargin); box-sizing: border-box; font: inherit; font-weight: normal; diff --git a/src/client/components/form/radios.vue b/src/client/components/form/radios.vue index 3daa7e5bb..4cfb7c247 100644 --- a/src/client/components/form/radios.vue +++ b/src/client/components/form/radios.vue @@ -18,6 +18,9 @@ export default defineComponent({ } }, watch: { + modelValue() { + this.value = this.modelValue; + }, value() { this.$emit('update:modelValue', this.value); } diff --git a/src/client/components/form/suspense.vue b/src/client/components/form/suspense.vue index 6a8282733..2a48faccb 100644 --- a/src/client/components/form/suspense.vue +++ b/src/client/components/form/suspense.vue @@ -5,9 +5,9 @@ <MkLoading/> </div> </div> - <FormGroup v-else-if="resolved" class="_formItem"> + <div v-else-if="resolved" class="_formItem"> <slot :result="result"></slot> - </FormGroup> + </div> <div class="_formItem" v-else> <div class="_formPanel"> error! @@ -20,13 +20,8 @@ <script lang="ts"> import { defineComponent, PropType, ref, watch } from 'vue'; import './form.scss'; -import FormGroup from './group.vue'; export default defineComponent({ - components: { - FormGroup, - }, - props: { p: { type: Function as PropType<() => Promise<any>>, diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue index aa01f1c80..432c9a1bb 100644 --- a/src/client/components/instance-stats.vue +++ b/src/client/components/instance-stats.vue @@ -1,123 +1,35 @@ <template> -<div class="zbcjwnqg" v-size="{ max: [550, 1000] }"> - <div class="stats" v-if="info"> - <div class="_panel"> - <div> - <b><i class="fas fa-user"></i>{{ $ts.users }}</b> - <small>{{ $ts.local }}</small> - </div> - <div> - <dl class="total"> - <dt>{{ $ts.total }}</dt> - <dd>{{ number(info.originalUsersCount) }}</dd> - </dl> - <dl class="diff" :class="{ inc: usersLocalDoD > 0 }"> - <dt>{{ $ts.dayOverDayChanges }}</dt> - <dd>{{ number(usersLocalDoD) }}</dd> - </dl> - <dl class="diff" :class="{ inc: usersLocalWoW > 0 }"> - <dt>{{ $ts.weekOverWeekChanges }}</dt> - <dd>{{ number(usersLocalWoW) }}</dd> - </dl> - </div> - </div> - <div class="_panel"> - <div> - <b><i class="fas fa-user"></i>{{ $ts.users }}</b> - <small>{{ $ts.remote }}</small> - </div> - <div> - <dl class="total"> - <dt>{{ $ts.total }}</dt> - <dd>{{ number((info.usersCount - info.originalUsersCount)) }}</dd> - </dl> - <dl class="diff" :class="{ inc: usersRemoteDoD > 0 }"> - <dt>{{ $ts.dayOverDayChanges }}</dt> - <dd>{{ number(usersRemoteDoD) }}</dd> - </dl> - <dl class="diff" :class="{ inc: usersRemoteWoW > 0 }"> - <dt>{{ $ts.weekOverWeekChanges }}</dt> - <dd>{{ number(usersRemoteWoW) }}</dd> - </dl> - </div> - </div> - <div class="_panel"> - <div> - <b><i class="fas fa-pencil-alt"></i>{{ $ts.notes }}</b> - <small>{{ $ts.local }}</small> - </div> - <div> - <dl class="total"> - <dt>{{ $ts.total }}</dt> - <dd>{{ number(info.originalNotesCount) }}</dd> - </dl> - <dl class="diff" :class="{ inc: notesLocalDoD > 0 }"> - <dt>{{ $ts.dayOverDayChanges }}</dt> - <dd>{{ number(notesLocalDoD) }}</dd> - </dl> - <dl class="diff" :class="{ inc: notesLocalWoW > 0 }"> - <dt>{{ $ts.weekOverWeekChanges }}</dt> - <dd>{{ number(notesLocalWoW) }}</dd> - </dl> - </div> - </div> - <div class="_panel"> - <div> - <b><i class="fas fa-pencil-alt"></i>{{ $ts.notes }}</b> - <small>{{ $ts.remote }}</small> - </div> - <div> - <dl class="total"> - <dt>{{ $ts.total }}</dt> - <dd>{{ number((info.notesCount - info.originalNotesCount)) }}</dd> - </dl> - <dl class="diff" :class="{ inc: notesRemoteDoD > 0 }"> - <dt>{{ $ts.dayOverDayChanges }}</dt> - <dd>{{ number(notesRemoteDoD) }}</dd> - </dl> - <dl class="diff" :class="{ inc: notesRemoteWoW > 0 }"> - <dt>{{ $ts.weekOverWeekChanges }}</dt> - <dd>{{ number(notesRemoteWoW) }}</dd> - </dl> - </div> - </div> +<div class="zbcjwnqg" style="margin-top: -8px;"> + <div class="selects" style="display: flex;"> + <MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="$ts.federation"> + <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option> + <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option> + </optgroup> + <optgroup :label="$ts.users"> + <option value="users">{{ $ts._charts.usersIncDec }}</option> + <option value="users-total">{{ $ts._charts.usersTotal }}</option> + <option value="active-users">{{ $ts._charts.activeUsers }}</option> + </optgroup> + <optgroup :label="$ts.notes"> + <option value="notes">{{ $ts._charts.notesIncDec }}</option> + <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> + <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> + <option value="notes-total">{{ $ts._charts.notesTotal }}</option> + </optgroup> + <optgroup :label="$ts.drive"> + <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> + <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option> + <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> + <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model:value="chartSpan" style="margin: 0;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> </div> - - <section class="_card"> - <div class="_title" style="position: relative;"><i class="fas fa-chart-bar"></i> {{ $ts.statistics }}<button @click="fetchChart" class="_button" style="position: absolute; right: 0; bottom: 0; top: 0; padding: inherit;"><i class="fas fa-sync"></i></button></div> - <div class="_content" style="margin-top: -8px;"> - <div class="selects" style="display: flex;"> - <MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;"> - <optgroup :label="$ts.federation"> - <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option> - <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option> - </optgroup> - <optgroup :label="$ts.users"> - <option value="users">{{ $ts._charts.usersIncDec }}</option> - <option value="users-total">{{ $ts._charts.usersTotal }}</option> - <option value="active-users">{{ $ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="$ts.notes"> - <option value="notes">{{ $ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> - <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ $ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="$ts.drive"> - <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> - <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option> - <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> - <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model:value="chartSpan" style="margin: 0;"> - <option value="hour">{{ $ts.perHour }}</option> - <option value="day">{{ $ts.perDay }}</option> - </MkSelect> - </div> - <canvas ref="chart"></canvas> - </div> - </section> + <canvas ref="chart"></canvas> </div> </template> @@ -158,7 +70,6 @@ export default defineComponent({ data() { return { - info: null, notesLocalWoW: 0, notesLocalDoD: 0, notesRemoteWoW: 0, @@ -216,8 +127,6 @@ export default defineComponent({ }, async created() { - this.info = await os.api('stats'); - this.now = new Date(); this.fetchChart(); @@ -256,15 +165,6 @@ export default defineComponent({ } }; - this.notesLocalWoW = this.info.originalNotesCount - chart.perDay.notes.local.total[7]; - this.notesLocalDoD = this.info.originalNotesCount - chart.perDay.notes.local.total[1]; - this.notesRemoteWoW = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[7]; - this.notesRemoteDoD = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[1]; - this.usersLocalWoW = this.info.originalUsersCount - chart.perDay.users.local.total[7]; - this.usersLocalDoD = this.info.originalUsersCount - chart.perDay.users.local.total[1]; - this.usersRemoteWoW = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[7]; - this.usersRemoteDoD = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[1]; - this.chart = chart; this.renderChart(); @@ -300,10 +200,10 @@ export default defineComponent({ aspectRatio: 2.5, layout: { padding: { - left: 0, - right: 0, + left: 16, + right: 16, top: 16, - bottom: 0 + bottom: 8 } }, legend: { @@ -630,90 +530,8 @@ export default defineComponent({ <style lang="scss" scoped> .zbcjwnqg { - &.max-width_1000px { - > .stats { - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr 1fr; - } - } - - &.max-width_550px { - > .stats { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr 1fr 1fr; - } - } - - > .stats { - display: grid; - grid-template-columns: 1fr 1fr 1fr 1fr; - grid-template-rows: 1fr; - gap: var(--margin); - margin-bottom: var(--margin); - font-size: 90%; - - > div { - display: flex; - box-sizing: border-box; - padding: 16px 20px; - - > div { - width: 50%; - - &:first-child { - > b { - display: block; - - > i { - width: 16px; - margin-right: 8px; - } - } - - > small { - margin-left: 16px + 8px; - opacity: 0.7; - } - } - - &:last-child { - > dl { - display: flex; - margin: 0; - line-height: 1.5em; - - > dt, - > dd { - width: 50%; - margin: 0; - } - - > dd { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } - - &.total { - > dt, - > dd { - font-weight: bold; - } - } - - &.diff.inc { - > dd { - color: #82c11c; - - &:before { - content: "+"; - } - } - } - } - } - } - } + > .selects { + padding: 8px 16px 0 16px; } } </style> diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue index 7b40561ad..671642b29 100644 --- a/src/client/components/signup.vue +++ b/src/client/components/signup.vue @@ -45,7 +45,7 @@ </I18n> </label> <captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model:value="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> - <captcha v-if="meta.enableRecaptcha" class="captcha" provider="grecaptcha" ref="recaptcha" v-model:value="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> + <captcha v-if="meta.enableRecaptcha" class="captcha" provider="recaptcha" ref="recaptcha" v-model:value="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> <MkButton type="submit" :disabled="shouldDisableSubmitting" primary>{{ $ts.start }}</MkButton> </template> </form> diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue index aca4d32a2..96cbe50fb 100644 --- a/src/client/components/tab.vue +++ b/src/client/components/tab.vue @@ -29,6 +29,7 @@ export default defineComponent({ <style lang="scss"> .pxhvhrfw { display: flex; + font-size: 90%; > button { flex: 1; diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue index 13181d39e..ac8ed01e1 100644 --- a/src/client/components/ui/pagination.vue +++ b/src/client/components/ui/pagination.vue @@ -1,16 +1,23 @@ <template> -<div class="cxiknjgy"> - <slot :items="items"></slot> - <div class="empty" v-if="empty" key="_empty_"> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <div class="empty" v-else-if="empty" key="_empty_"> <slot name="empty"></slot> </div> - <div class="more" v-show="more" key="_more_"> - <MkButton class="button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> + + <div v-else class="cxiknjgy"> + <slot :items="items"></slot> + <div class="more" v-show="more" key="_more_"> + <MkButton class="button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> </div> -</div> +</transition> </template> <script lang="ts"> @@ -36,6 +43,15 @@ export default defineComponent({ </script> <style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + .cxiknjgy { > .more > .button { margin-left: auto; diff --git a/src/client/pages/instance/abuses.vue b/src/client/pages/instance/abuses.vue index 7666bc1a4..73196027d 100644 --- a/src/client/pages/instance/abuses.vue +++ b/src/client/pages/instance/abuses.vue @@ -1,5 +1,5 @@ <template> -<div class=""> +<div class="lcixvhis"> <div class="_section reports"> <div class="_content"> <div class="inputs" style="display: flex;"> @@ -80,6 +80,8 @@ export default defineComponent({ MkPagination, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { @@ -117,6 +119,10 @@ export default defineComponent({ }, }, + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + methods: { acct, @@ -132,6 +138,10 @@ export default defineComponent({ </script> <style lang="scss" scoped> +.lcixvhis { + margin: var(--margin); +} + .bcekxzvu { > .target { display: flex; diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/instance/announcements.vue index 6a00476f9..ac0e9d513 100644 --- a/src/client/pages/instance/announcements.vue +++ b/src/client/pages/instance/announcements.vue @@ -1,28 +1,24 @@ <template> <div class="ztgjmzrw"> - <div class="_section"> - <div class="_content"> - <MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> - <section class="_card _gap announcements" v-for="announcement in announcements"> - <div class="_content announcement"> - <MkInput v-model:value="announcement.title"> - <span>{{ $ts.title }}</span> - </MkInput> - <MkTextarea v-model:value="announcement.text"> - <span>{{ $ts.text }}</span> - </MkTextarea> - <MkInput v-model:value="announcement.imageUrl"> - <span>{{ $ts.imageUrl }}</span> - </MkInput> - <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> - <div class="buttons"> - <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> - </div> - </div> - </section> + <MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + <section class="_card _gap announcements" v-for="announcement in announcements"> + <div class="_content announcement"> + <MkInput v-model:value="announcement.title"> + <span>{{ $ts.title }}</span> + </MkInput> + <MkTextarea v-model:value="announcement.text"> + <span>{{ $ts.text }}</span> + </MkTextarea> + <MkInput v-model:value="announcement.imageUrl"> + <span>{{ $ts.imageUrl }}</span> + </MkInput> + <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + </div> </div> - </div> + </section> </div> </template> @@ -41,6 +37,8 @@ export default defineComponent({ MkTextarea, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { @@ -57,6 +55,10 @@ export default defineComponent({ }); }, + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + methods: { add() { this.announcements.unshift({ @@ -109,3 +111,9 @@ export default defineComponent({ } }); </script> + +<style lang="scss" scoped> +.ztgjmzrw { + margin: var(--margin); +} +</style> diff --git a/src/client/pages/instance/bot-protection.vue b/src/client/pages/instance/bot-protection.vue new file mode 100644 index 000000000..449b8a233 --- /dev/null +++ b/src/client/pages/instance/bot-protection.vue @@ -0,0 +1,138 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormRadios v-model="provider"> + <template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template> + <option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option> + <option value="hcaptcha">hCaptcha</option> + <option value="recaptcha">reCAPTCHA</option> + </FormRadios> + + <template v-if="provider === 'hcaptcha'"> + <div class="_formItem _formNoConcat" v-sticky-container> + <div class="_formLabel">hCaptcha</div> + <div class="main"> + <FormInput v-model:value="hcaptchaSiteKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.hcaptchaSiteKey }}</span> + </FormInput> + <FormInput v-model:value="hcaptchaSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.hcaptchaSecretKey }}</span> + </FormInput> + </div> + </div> + <div class="_formItem _formNoConcat" v-sticky-container> + <div class="_formLabel">{{ $ts.preview }}</div> + <div class="_formPanel" style="padding: var(--formContentHMargin);"> + <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> + </div> + </div> + </template> + <template v-else-if="provider === 'recaptcha'"> + <div class="_formItem _formNoConcat" v-sticky-container> + <div class="_formLabel">reCAPTCHA</div> + <div class="main"> + <FormInput v-model:value="recaptchaSiteKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.recaptchaSiteKey }}</span> + </FormInput> + <FormInput v-model:value="recaptchaSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.recaptchaSecretKey }}</span> + </FormInput> + </div> + </div> + <div v-if="recaptchaSiteKey" class="_formItem _formNoConcat" v-sticky-container> + <div class="_formLabel">{{ $ts.preview }}</div> + <div class="_formPanel" style="padding: var(--formContentHMargin);"> + <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> + </div> + </div> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormRadios from '@client/components/form/radios.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormRadios, + FormInput, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + MkCaptcha: defineAsyncComponent(() => import('@client/components/captcha.vue')), + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.botProtection, + icon: 'fas fa-shield-alt' + }, + provider: null, + enableHcaptcha: false, + hcaptchaSiteKey: null, + hcaptchaSecretKey: null, + enableRecaptcha: false, + recaptchaSiteKey: null, + recaptchaSecretKey: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableHcaptcha = meta.enableHcaptcha; + this.hcaptchaSiteKey = meta.hcaptchaSiteKey; + this.hcaptchaSecretKey = meta.hcaptchaSecretKey; + this.enableRecaptcha = meta.enableRecaptcha; + this.recaptchaSiteKey = meta.recaptchaSiteKey; + this.recaptchaSecretKey = meta.recaptchaSecretKey; + + this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null; + + this.$watch(() => this.provider, () => { + this.enableHcaptcha = this.provider === 'hcaptcha'; + this.enableRecaptcha = this.provider === 'recaptcha'; + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + enableHcaptcha: this.enableHcaptcha, + hcaptchaSiteKey: this.hcaptchaSiteKey, + hcaptchaSecretKey: this.hcaptchaSecretKey, + enableRecaptcha: this.enableRecaptcha, + recaptchaSiteKey: this.recaptchaSiteKey, + recaptchaSecretKey: this.recaptchaSecretKey, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/database.vue b/src/client/pages/instance/database.vue new file mode 100644 index 000000000..a41d61ce2 --- /dev/null +++ b/src/client/pages/instance/database.vue @@ -0,0 +1,60 @@ +<template> +<FormBase> + <FormSuspense :p="databasePromiseFactory" v-slot="{ result: database }"> + <FormGroup v-for="table in database" :key="table[0]"> + <template #label>{{ table[0] }}</template> + <FormKeyValueView> + <template #key>Size</template> + <template #value>{{ bytes(table[1].size) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Records</template> + <template #value>{{ number(table[1].count) }}</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import FormKeyValueView from '@client/components/form/key-value-view.vue'; +import FormLink from '@client/components/form/link.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import bytes from '@client/filters/bytes'; +import number from '@client/filters/number'; + +export default defineComponent({ + components: { + FormSuspense, + FormKeyValueView, + FormBase, + FormGroup, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.database, + icon: 'fas fa-database' + }, + databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + bytes, number, + } +}); +</script> diff --git a/src/client/pages/instance/email-settings.vue b/src/client/pages/instance/email-settings.vue new file mode 100644 index 000000000..9965a1420 --- /dev/null +++ b/src/client/pages/instance/email-settings.vue @@ -0,0 +1,127 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model:value="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch> + + <template v-if="enableEmail"> + <FormInput v-model:value="email" type="email"> + <span>{{ $ts.emailAddress }}</span> + </FormInput> + + <div class="_formItem _formNoConcat" v-sticky-container> + <div class="_formLabel">{{ $ts.smtpConfig }}</div> + <div class="main"> + <FormInput v-model:value="smtpHost"> + <span>{{ $ts.smtpHost }}</span> + </FormInput> + <FormInput v-model:value="smtpPort" type="number"> + <span>{{ $ts.smtpPort }}</span> + </FormInput> + <FormInput v-model:value="smtpUser"> + <span>{{ $ts.smtpUser }}</span> + </FormInput> + <FormInput v-model:value="smtpPass" type="password"> + <span>{{ $ts.smtpPass }}</span> + </FormInput> + <FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo> + <FormSwitch v-model:value="smtpSecure">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></FormSwitch> + </div> + </div> + + <FormButton @click="testEmail">{{ $ts.testEmail }}</FormButton> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailServer, + icon: 'fas fa-envelope' + }, + enableEmail: false, + email: null, + smtpSecure: false, + smtpHost: '', + smtpPort: 0, + smtpUser: '', + smtpPass: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableEmail = meta.enableEmail; + this.email = meta.email; + this.smtpSecure = meta.smtpSecure; + this.smtpHost = meta.smtpHost; + this.smtpPort = meta.smtpPort; + this.smtpUser = meta.smtpUser; + this.smtpPass = meta.smtpPass; + }, + + async testEmail() { + const { canceled, result: destination } = await os.dialog({ + title: this.$ts.destination, + input: { + placeholder: this.$instance.maintainerEmail + } + }); + if (canceled) return; + os.apiWithDialog('admin/send-email', { + to: destination, + subject: 'Test email', + text: 'Yo' + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + enableEmail: this.enableEmail, + email: this.email, + smtpSecure: this.smtpSecure, + smtpHost: this.smtpHost, + smtpPort: this.smtpPort, + smtpUser: this.smtpUser, + smtpPass: this.smtpPass, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/instance/emojis.vue index 88bebb40e..fd641703c 100644 --- a/src/client/pages/instance/emojis.vue +++ b/src/client/pages/instance/emojis.vue @@ -1,50 +1,46 @@ <template> -<div class="mk-instance-emojis"> - <div class="_section" style="padding: 0;"> - <MkTab v-model:value="tab"> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> - </MkTab> +<div class="ogwlenmc"> + <MkTab v-model:value="tab"> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkTab> + + <div class="local" v-if="tab === 'local'"> + <MkButton primary @click="add" style="margin: var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.addEmoji }}</MkButton> + <MkInput v-model:value="query" :debounce="true" type="search" style="margin: var(--margin);"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput> + <MkPagination :pagination="pagination" ref="emojis"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> </div> - <div class="_section"> - <div class="local" v-if="tab === 'local'"> - <MkButton primary @click="add" style="margin: 0 auto var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.addEmoji }}</MkButton> - <MkInput v-model:value="query" :debounce="true" type="search"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput> - <MkPagination :pagination="pagination" ref="emojis"> - <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="emojis"> - <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name">{{ emoji.name }}</div> - <div class="info">{{ emoji.category }}</div> - </div> - </button> - </div> - </template> - </MkPagination> - </div> - - <div class="remote" v-else-if="tab === 'remote'"> - <MkInput v-model:value="queryRemote" :debounce="true" type="search"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput> - <MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput> - <MkPagination :pagination="remotePagination" ref="remoteEmojis"> - <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> - <template #default="{items}"> - <div class="emojis"> - <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name">{{ emoji.name }}</div> - <div class="info">{{ emoji.host }}</div> - </div> + <div class="remote" v-else-if="tab === 'remote'"> + <MkInput v-model:value="queryRemote" :debounce="true" type="search" style="margin: var(--margin);"><template #icon><i class="fas fa-search"></i></template><span>{{ $ts.search }}</span></MkInput> + <MkInput v-model:value="host" :debounce="true" style="margin: var(--margin);"><span>{{ $ts.host }}</span></MkInput> + <MkPagination :pagination="remotePagination" ref="remoteEmojis"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> </div> </div> - </template> - </MkPagination> - </div> + </div> + </template> + </MkPagination> </div> </div> </template> @@ -67,6 +63,8 @@ export default defineComponent({ MkPagination, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { @@ -99,6 +97,10 @@ export default defineComponent({ } }, + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + methods: { async add(e) { const files = await selectFile(e.currentTarget || e.target, null, true); @@ -150,85 +152,86 @@ export default defineComponent({ </script> <style lang="scss" scoped> -.mk-instance-emojis { - > ._section { - > .local { - .emojis { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - grid-gap: var(--margin); - - > .emoji { - display: flex; - align-items: center; - padding: 12px; - text-align: left; +.ogwlenmc { + > .local { + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; - &:hover { - color: var(--accent); - } + &:hover { + color: var(--accent); + } - > .img { - width: 42px; - height: 42px; - } + > .img { + width: 42px; + height: 42px; + } - > .body { - padding: 0 0 0 8px; - white-space: nowrap; + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; overflow: hidden; + } - > .name { - text-overflow: ellipsis; - overflow: hidden; - } - - > .info { - opacity: 0.5; - text-overflow: ellipsis; - overflow: hidden; - } + > .info { + opacity: 0.5; + text-overflow: ellipsis; + overflow: hidden; } } } } + } - > .remote { - .emojis { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); - grid-gap: var(--margin); + > .remote { + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin); - > .emoji { - display: flex; - align-items: center; - padding: 12px; - text-align: left; + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; - &:hover { - color: var(--accent); - } + &:hover { + color: var(--accent); + } - > .img { - width: 32px; - height: 32px; - } + > .img { + width: 32px; + height: 32px; + } - > .body { - padding: 0 0 0 8px; - white-space: nowrap; + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; overflow: hidden; + } - > .name { - text-overflow: ellipsis; - overflow: hidden; - } - - > .info { - opacity: 0.5; - text-overflow: ellipsis; - overflow: hidden; - } + > .info { + opacity: 0.5; + font-size: 90%; + text-overflow: ellipsis; + overflow: hidden; } } } diff --git a/src/client/pages/instance/federation.vue b/src/client/pages/instance/federation.vue index 1b69fc291..96f72fed4 100644 --- a/src/client/pages/instance/federation.vue +++ b/src/client/pages/instance/federation.vue @@ -1,60 +1,55 @@ <template> -<div> - <div class="_section"> - <div class="_content"> - <MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput> - <div class="inputs" style="display: flex;"> - <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="federating">{{ $ts.federating }}</option> - <option value="subscribing">{{ $ts.subscribing }}</option> - <option value="publishing">{{ $ts.publishing }}</option> - <option value="suspended">{{ $ts.suspended }}</option> - <option value="blocked">{{ $ts.blocked }}</option> - <option value="notResponding">{{ $ts.notResponding }}</option> - </MkSelect> - <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.sort }}</template> - <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> - <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> - <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> - <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> - <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> - <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> - <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> - <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> - <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> - <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> - <option value="+caughtAt">{{ $ts.caughtAt }} ({{ $ts.descendingOrder }})</option> - <option value="-caughtAt">{{ $ts.caughtAt }} ({{ $ts.ascendingOrder }})</option> - <option value="+lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.descendingOrder }})</option> - <option value="-lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.ascendingOrder }})</option> - <option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option> - <option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option> - <option value="+driveFiles">{{ $ts.driveFiles }} ({{ $ts.descendingOrder }})</option> - <option value="-driveFiles">{{ $ts.driveFiles }} ({{ $ts.ascendingOrder }})</option> - </MkSelect> +<div class="enuoauvw"> + <div class="query"> + <MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput> + <div class="inputs" style="display: flex;"> + <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="federating">{{ $ts.federating }}</option> + <option value="subscribing">{{ $ts.subscribing }}</option> + <option value="publishing">{{ $ts.publishing }}</option> + <option value="suspended">{{ $ts.suspended }}</option> + <option value="blocked">{{ $ts.blocked }}</option> + <option value="notResponding">{{ $ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.sort }}</template> + <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> + <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> + <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> + <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> + <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> + <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> + <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> + <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> + <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> + <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> + <option value="+caughtAt">{{ $ts.caughtAt }} ({{ $ts.descendingOrder }})</option> + <option value="-caughtAt">{{ $ts.caughtAt }} ({{ $ts.ascendingOrder }})</option> + <option value="+lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.descendingOrder }})</option> + <option value="-lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.ascendingOrder }})</option> + <option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option> + <option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option> + <option value="+driveFiles">{{ $ts.driveFiles }} ({{ $ts.descendingOrder }})</option> + <option value="-driveFiles">{{ $ts.driveFiles }} ({{ $ts.ascendingOrder }})</option> + </MkSelect> + </div> + </div> + + <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state"> + <div class="ppgwaixt _block" v-for="instance in items" :key="instance.id" @click="info(instance)"> + <div class="host"><i class="fas fa-circle indicator" :class="getStatus(instance)"></i><b>{{ instance.host }}</b></div> + <div class="status"> + <span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span> + <span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span> + <span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span> + <span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span> + <span class="lastCommunicatedAt"><i class="fas fa-exchange-alt icon"></i><MkTime :time="instance.lastCommunicatedAt"/></span> + <span class="latestStatus"><i class="fas fa-traffic-light icon"></i>{{ instance.latestStatus || '-' }}</span> </div> </div> - </div> - <div class="_section"> - <div class="_content"> - <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state"> - <div class="ppgwaixt _panel" v-for="instance in items" :key="instance.id" @click="info(instance)"> - <div class="host"><i class="fas fa-circle indicator" :class="getStatus(instance)"></i><b>{{ instance.host }}</b></div> - <div class="status"> - <span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span> - <span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span> - <span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span> - <span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span> - <span class="lastCommunicatedAt"><i class="fas fa-exchange-alt icon"></i><MkTime :time="instance.lastCommunicatedAt"/></span> - <span class="latestStatus"><i class="fas fa-traffic-light icon"></i>{{ instance.latestStatus || '-' }}</span> - </div> - </div> - </MkPagination> - </div> - </div> + </MkPagination> </div> </template> @@ -76,6 +71,8 @@ export default defineComponent({ MkPagination, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { @@ -114,6 +111,10 @@ export default defineComponent({ } }, + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + methods: { getStatus(instance) { if (instance.isSuspended) return 'off'; @@ -131,6 +132,12 @@ export default defineComponent({ </script> <style lang="scss" scoped> +.enuoauvw { + > .query { + margin: var(--margin); + } +} + .ppgwaixt { cursor: pointer; padding: 16px; diff --git a/src/client/pages/instance/file-dialog.vue b/src/client/pages/instance/file-dialog.vue index 1220a5193..ae6755465 100644 --- a/src/client/pages/instance/file-dialog.vue +++ b/src/client/pages/instance/file-dialog.vue @@ -82,9 +82,7 @@ export default defineComponent({ }, showUser() { - os.popup(import('./user-dialog.vue'), { - userId: this.file.userId - }, {}, 'closed'); + os.pageWindow(`/instance/user/${this.file.userId}`); }, async del() { diff --git a/src/client/pages/instance/files-settings.vue b/src/client/pages/instance/files-settings.vue new file mode 100644 index 000000000..614c7d4db --- /dev/null +++ b/src/client/pages/instance/files-settings.vue @@ -0,0 +1,92 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model:value="cacheRemoteFiles"> + {{ $ts.cacheRemoteFiles }} + <template #desc>{{ $ts.cacheRemoteFilesDescription }}</template> + </FormSwitch> + + <FormSwitch v-model:value="proxyRemoteFiles"> + {{ $ts.proxyRemoteFiles }} + <template #desc>{{ $ts.proxyRemoteFilesDescription }}</template> + </FormSwitch> + + <FormInput v-model:value="localDriveCapacityMb" type="number"> + <span>{{ $ts.driveCapacityPerLocalAccount }}</span> + <template #suffix>MB</template> + <template #desc>{{ $ts.inMb }}</template> + </FormInput> + + <FormInput v-model:value="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles"> + <span>{{ $ts.driveCapacityPerRemoteAccount }}</span> + <template #suffix>MB</template> + <template #desc>{{ $ts.inMb }}</template> + </FormInput> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.files, + icon: 'fas fa-cloud' + }, + cacheRemoteFiles: false, + proxyRemoteFiles: false, + localDriveCapacityMb: 0, + remoteDriveCapacityMb: 0, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.cacheRemoteFiles = meta.cacheRemoteFiles; + this.proxyRemoteFiles = meta.proxyRemoteFiles; + this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; + this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; + }, + save() { + os.apiWithDialog('admin/update-meta', { + cacheRemoteFiles: this.cacheRemoteFiles, + proxyRemoteFiles: this.proxyRemoteFiles, + localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10), + remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10), + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/files.vue b/src/client/pages/instance/files.vue index ed46dd466..427c5b411 100644 --- a/src/client/pages/instance/files.vue +++ b/src/client/pages/instance/files.vue @@ -80,6 +80,8 @@ export default defineComponent({ MkDriveFileThumbnail, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { @@ -114,6 +116,10 @@ export default defineComponent({ }, }, + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + methods: { clear() { os.dialog({ @@ -153,6 +159,8 @@ export default defineComponent({ <style lang="scss" scoped> .xrmjdkdw { + margin: var(--margin); + .urempief { margin-top: var(--margin); diff --git a/src/client/pages/instance/index.vue b/src/client/pages/instance/index.vue index f0240718a..10406f339 100644 --- a/src/client/pages/instance/index.vue +++ b/src/client/pages/instance/index.vue @@ -1,171 +1,239 @@ <template> -<div v-if="meta" v-show="page === 'index'" class="xhexznfu _section"> - <MkFolder> - <template #header><i class="fas fa-tachometer-alt"></i> {{ $ts.overview }}</template> - - <div class="sboqnrfi" :style="{ gridTemplateRows: overviewHeight }"> - <MkInstanceStats :chart-limit="300" :detailed="true" class="_gap" ref="stats"/> - - <MkContainer :foldable="true" class="_gap"> - <template #header><i class="fas fa-info-circle"></i>{{ $ts.instanceInfo }}</template> - - <div class="_content"> - <div class="_keyValue"><b>Misskey</b><span>v{{ version }}</span></div> +<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || page == null"> + <FormBase> + <FormGroup> + <div class="_formItem"> + <div class="_formPanel lxpfedzu"> + <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> + </div> </div> - <div class="_content" v-if="serverInfo"> - <div class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div> - <div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div> - <div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div> - </div> - </MkContainer> - - <MkContainer :foldable="true" :scrollable="true" class="_gap" style="height: 300px;"> - <template #header><i class="fas fa-database"></i>{{ $ts.database }}</template> - - <div class="_content" v-if="dbInfo"> - <table style="border-collapse: collapse; width: 100%;"> - <tr style="opacity: 0.7;"> - <th style="text-align: left; padding: 0 8px 8px 0;">Table</th> - <th style="text-align: left; padding: 0 8px 8px 0;">Records</th> - <th style="text-align: left; padding: 0 0 8px 0;">Size</th> - </tr> - <tr v-for="table in dbInfo" :key="table[0]"> - <th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th> - <td style="padding: 0 8px 0 0;">{{ number(table[1].count) }}</td> - <td style="padding: 0; opacity: 0.7;">{{ bytes(table[1].size) }}</td> - </tr> - </table> - </div> - </MkContainer> - </div> - </MkFolder> -</div> -<div v-if="page === 'logs'" class="_section"> - <MkFolder> - <template #header><i class="fas fa-stream"></i> {{ $ts.logs }}</template> - - <div class="_keyValue" v-for="log in modLogs"> - <b>{{ log.type }}</b><span>by {{ log.user.username }}</span><MkTime :time="log.createdAt" style="opacity: 0.7;"/> - </div> - </MkFolder> -</div> -<div v-if="page === 'metrics'"> - <XMetrics/> + <FormLink :active="page === 'overview'" replace to="/instance/overview"><template #icon><i class="fas fa-tachometer-alt"></i></template>{{ $ts.overview }}</FormLink> + </FormGroup> + <FormGroup> + <template #label>{{ $ts.quickAction }}</template> + <FormButton @click="lookup"><i class="fas fa-search"></i> {{ $ts.lookup }}</FormButton> + <FormButton v-if="$instance.disableRegistration" @click="invite"><i class="fas fa-user"></i> {{ $ts.invite }}</FormButton> + </FormGroup> + <FormGroup> + <FormLink :active="page === 'users'" replace to="/instance/users"><template #icon><i class="fas fa-users"></i></template>{{ $ts.users }}</FormLink> + <FormLink :active="page === 'emojis'" replace to="/instance/emojis"><template #icon><i class="fas fa-laugh"></i></template>{{ $ts.customEmojis }}</FormLink> + <FormLink :active="page === 'federation'" replace to="/instance/federation"><template #icon><i class="fas fa-globe"></i></template>{{ $ts.federation }}</FormLink> + <FormLink :active="page === 'queue'" replace to="/instance/queue"><template #icon><i class="fas fa-clipboard-list"></i></template>{{ $ts.jobQueue }}</FormLink> + <FormLink :active="page === 'files'" replace to="/instance/files"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink> + <FormLink :active="page === 'announcements'" replace to="/instance/announcements"><template #icon><i class="fas fa-broadcast-tower"></i></template>{{ $ts.announcements }}</FormLink> + <FormLink :active="page === 'database'" replace to="/instance/database"><template #icon><i class="fas fa-database"></i></template>{{ $ts.database }}</FormLink> + <FormLink :active="page === 'abuses'" replace to="/instance/abuses"><template #icon><i class="fas fa-exclamation-circle"></i></template>{{ $ts.abuseReports }}</FormLink> + </FormGroup> + <FormGroup> + <template #label>{{ $ts.settings }}</template> + <FormLink :active="page === 'settings'" replace to="/instance/settings"><template #icon><i class="fas fa-cog"></i></template>{{ $ts.general }}</FormLink> + <FormLink :active="page === 'files-settings'" replace to="/instance/files-settings"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink> + <FormLink :active="page === 'email-settings'" replace to="/instance/email-settings"><template #icon><i class="fas fa-envelope"></i></template>{{ $ts.emailServer }}</FormLink> + <FormLink :active="page === 'object-storage'" replace to="/instance/object-storage"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.objectStorage }}</FormLink> + <FormLink :active="page === 'security'" replace to="/instance/security"><template #icon><i class="fas fa-lock"></i></template>{{ $ts.security }}</FormLink> + <FormLink :active="page === 'service-worker'" replace to="/instance/service-worker"><template #icon><i class="fas fa-bolt"></i></template>ServiceWorker</FormLink> + <FormLink :active="page === 'relays'" replace to="/instance/relays"><template #icon><i class="fas fa-globe"></i></template>{{ $ts.relays }}</FormLink> + <FormLink :active="page === 'integrations'" replace to="/instance/integrations"><template #icon><i class="fas fa-share-alt"></i></template>{{ $ts.integration }}</FormLink> + <FormLink :active="page === 'instance-block'" replace to="/instance/instance-block"><template #icon><i class="fas fa-ban"></i></template>{{ $ts.instanceBlocking }}</FormLink> + <FormLink :active="page === 'proxy-account'" replace to="/instance/proxy-account"><template #icon><i class="fas fa-ghost"></i></template>{{ $ts.proxyAccount }}</FormLink> + <FormLink :active="page === 'other-settings'" replace to="/instance/other-settings"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.other }}</FormLink> + </FormGroup> + </FormBase> + </div> + <div class="main"> + <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/> + </div> </div> </template> <script lang="ts"> -import { computed, defineComponent, markRaw } from 'vue'; -import VueJsonPretty from 'vue-json-pretty'; -import MkInstanceStats from '@client/components/instance-stats.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkSelect from '@client/components/ui/select.vue'; -import MkInput from '@client/components/ui/input.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import { version, url } from '@client/config'; -import bytes from '../../filters/bytes'; -import number from '../../filters/number'; -import MkInstanceInfo from './instance.vue'; -import XMetrics from './index.metrics.vue'; -import * as os from '@client/os'; +import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'; +import { i18n } from '@client/i18n'; +import FormLink from '@client/components/form/link.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormButton from '@client/components/form/button.vue'; +import { scroll } from '@client/scripts/scroll'; import * as symbols from '@client/symbols'; +import * as os from '@client/os'; +import { lookupUser } from '@client/scripts/lookup-user'; export default defineComponent({ components: { - MkInstanceStats, - MkButton, - MkSelect, - MkInput, - MkContainer, - MkFolder, - XMetrics, - VueJsonPretty, + FormBase, + FormLink, + FormGroup, + FormButton, }, - data() { - return { - [symbols.PAGE_INFO]: { - tabs: [{ - id: 'index', - title: null, - tooltip: this.$ts.instance, - icon: 'fas fa-server', - onClick: () => { this.page = 'index'; }, - selected: computed(() => this.page === 'index') - }, { - id: 'metrics', - title: null, - tooltip: this.$ts.metrics, - icon: 'fas fa-heartbeat', - onClick: () => { this.page = 'metrics'; }, - selected: computed(() => this.page === 'metrics') - }, { - id: 'logs', - title: null, - tooltip: this.$ts.logs, - icon: 'fas fa-stream', - onClick: () => { this.page = 'logs'; }, - selected: computed(() => this.page === 'logs') - }] - }, - page: 'index', - version, - url, - stats: null, - serverInfo: null, - modLogs: [], - dbInfo: null, + props: { + initialPage: { + type: String, + required: false } }, - computed: { - meta() { - return this.$instance; - }, - }, - - mounted() { - this.fetchJobs(); - this.fetchModLogs(); - - os.api('admin/server-info', {}).then(res => { - this.serverInfo = res; - }); - - os.api('admin/get-table-stats', {}).then(res => { - this.dbInfo = Object.entries(res).sort((a, b) => b[1].size - a[1].size); - }); - }, - - methods: { - async showInstanceInfo(q) { - let instance = q; - if (typeof q === 'string') { - instance = await os.api('federation/show-instance', { - host: q - }); + setup(props, context) { + const indexInfo = { + title: i18n.locale.instance, + icon: 'fas fa-cog' + }; + const INFO = ref(indexInfo); + const page = ref(props.initialPage); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const onInfo = (viewInfo) => { + INFO.value = viewInfo; + }; + const pageProps = ref({}); + const component = computed(() => { + if (page.value == null) return null; + switch (page.value) { + case 'overview': return defineAsyncComponent(() => import('./overview.vue')); + case 'users': return defineAsyncComponent(() => import('./users.vue')); + case 'emojis': return defineAsyncComponent(() => import('./emojis.vue')); + case 'federation': return defineAsyncComponent(() => import('./federation.vue')); + case 'queue': return defineAsyncComponent(() => import('./queue.vue')); + case 'files': return defineAsyncComponent(() => import('./files.vue')); + case 'announcements': return defineAsyncComponent(() => import('./announcements.vue')); + case 'database': return defineAsyncComponent(() => import('./database.vue')); + case 'abuses': return defineAsyncComponent(() => import('./abuses.vue')); + case 'settings': return defineAsyncComponent(() => import('./settings.vue')); + case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue')); + case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue')); + case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue')); + case 'security': return defineAsyncComponent(() => import('./security.vue')); + case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue')); + case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue')); + case 'relays': return defineAsyncComponent(() => import('./relays.vue')); + case 'integrations': return defineAsyncComponent(() => import('./integrations.vue')); + case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue')); + case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue')); + case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue')); + case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue')); + case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue')); + case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue')); } - os.popup(MkInstanceInfo, { - instance: instance - }, {}, 'closed'); - }, + }); - fetchJobs() { - os.api('admin/queue/deliver-delayed', {}).then(jobs => { - this.jobs = jobs; + watch(component, () => { + pageProps.value = {}; + + nextTick(() => { + scroll(el.value, 0); }); - }, + }, { immediate: true }); - fetchModLogs() { - os.api('admin/show-moderation-logs', {}).then(logs => { - this.modLogs = logs; + watch(() => props.initialPage, () => { + if (props.initialPage == null && !narrow.value) { + page.value = 'overview'; + } else { + page.value = props.initialPage; + if (props.initialPage == null) { + INFO.value = indexInfo; + } + } + }); + + onMounted(() => { + narrow.value = el.value.offsetWidth < 800; + if (!narrow.value) { + page.value = 'overview'; + } + }); + + const invite = () => { + os.api('admin/invite').then(x => { + os.dialog({ + type: 'info', + text: x.code + }); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); }); - }, + }; - bytes, + const lookup = (ev) => { + os.modalMenu([{ + text: i18n.locale.user, + icon: 'fas fa-user', + action: () => { + lookupUser(); + } + }, { + text: i18n.locale.note, + icon: 'fas fa-pencil-alt', + action: () => { + alert('TODO'); + } + }, { + text: i18n.locale.file, + icon: 'fas fa-cloud', + action: () => { + alert('TODO'); + } + }, { + text: i18n.locale.instance, + icon: 'fas fa-globe', + action: () => { + alert('TODO'); + } + }], ev.currentTarget || ev.target); + }; - number, - } + return { + [symbols.PAGE_INFO]: INFO, + page, + narrow, + view, + el, + onInfo, + pageProps, + component, + invite, + lookup, + }; + }, }); </script> + +<style lang="scss" scoped> +.hiyeyicy { + &.wide { + display: flex; + max-width: 1100px; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + box-sizing: border-box; + border-right: solid 0.5px var(--divider); + overflow: auto; + } + + > .main { + flex: 1; + min-width: 0; + overflow: auto; + --baseContentWidth: 100%; + } + } +} + +.lxpfedzu { + padding: 16px; + + > img { + display: block; + margin: auto; + height: 42px; + border-radius: 8px; + } +} +</style> diff --git a/src/client/pages/instance/instance-block.vue b/src/client/pages/instance/instance-block.vue new file mode 100644 index 000000000..ed5740f33 --- /dev/null +++ b/src/client/pages/instance/instance-block.vue @@ -0,0 +1,71 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormTextarea v-model:value="blockedHosts"> + <span>{{ $ts.blockedInstances }}</span> + <template #desc>{{ $ts.blockedInstancesDescription }}</template> + </FormTextarea> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceBlocking, + icon: 'fas fa-ban' + }, + blockedHosts: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.blockedHosts = meta.blockedHosts.join('\n'); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + blockedHosts: this.blockedHosts.split('\n') || [], + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/integrations-discord.vue b/src/client/pages/instance/integrations-discord.vue new file mode 100644 index 000000000..c7508918f --- /dev/null +++ b/src/client/pages/instance/integrations-discord.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model:value="enableDiscordIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableDiscordIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo> + + <FormInput v-model:value="discordClientId"> + <template #prefix><i class="fas fa-key"></i></template> + Client ID + </FormInput> + + <FormInput v-model:value="discordClientSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Client Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Discord', + icon: 'fab fa-discord' + }, + enableDiscordIntegration: false, + discordClientId: null, + discordClientSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableDiscordIntegration = meta.enableDiscordIntegration; + this.discordClientId = meta.discordClientId; + this.discordClientSecret = meta.discordClientSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableDiscordIntegration: this.enableDiscordIntegration, + discordClientId: this.discordClientId, + discordClientSecret: this.discordClientSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/integrations-github.vue b/src/client/pages/instance/integrations-github.vue new file mode 100644 index 000000000..16586b15b --- /dev/null +++ b/src/client/pages/instance/integrations-github.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model:value="enableGithubIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableGithubIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo> + + <FormInput v-model:value="githubClientId"> + <template #prefix><i class="fas fa-key"></i></template> + Client ID + </FormInput> + + <FormInput v-model:value="githubClientSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Client Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'GitHub', + icon: 'fab fa-github' + }, + enableGithubIntegration: false, + githubClientId: null, + githubClientSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableGithubIntegration = meta.enableGithubIntegration; + this.githubClientId = meta.githubClientId; + this.githubClientSecret = meta.githubClientSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableGithubIntegration: this.enableGithubIntegration, + githubClientId: this.githubClientId, + githubClientSecret: this.githubClientSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/integrations-twitter.vue b/src/client/pages/instance/integrations-twitter.vue new file mode 100644 index 000000000..b08b7f40a --- /dev/null +++ b/src/client/pages/instance/integrations-twitter.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model:value="enableTwitterIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableTwitterIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo> + + <FormInput v-model:value="twitterConsumerKey"> + <template #prefix><i class="fas fa-key"></i></template> + Consumer Key + </FormInput> + + <FormInput v-model:value="twitterConsumerSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Consumer Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Twitter', + icon: 'fab fa-twitter' + }, + enableTwitterIntegration: false, + twitterConsumerKey: null, + twitterConsumerSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.twitterConsumerKey = meta.twitterConsumerKey; + this.twitterConsumerSecret = meta.twitterConsumerSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableTwitterIntegration: this.enableTwitterIntegration, + twitterConsumerKey: this.twitterConsumerKey, + twitterConsumerSecret: this.twitterConsumerSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/integrations.vue b/src/client/pages/instance/integrations.vue new file mode 100644 index 000000000..7debedc36 --- /dev/null +++ b/src/client/pages/instance/integrations.vue @@ -0,0 +1,73 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormLink to="/instance/integrations/twitter"> + <i class="fab fa-twitter"></i> Twitter + <template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + <FormLink to="/instance/integrations/github"> + <i class="fab fa-github"></i> GitHub + <template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + <FormLink to="/instance/integrations/discord"> + <i class="fab fa-discord"></i> Discord + <template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormLink from '@client/components/form/link.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormLink, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.integration, + icon: 'fas fa-share-alt' + }, + enableTwitterIntegration: false, + enableGithubIntegration: false, + enableDiscordIntegration: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.enableGithubIntegration = meta.enableGithubIntegration; + this.enableDiscordIntegration = meta.enableDiscordIntegration; + }, + } +}); +</script> diff --git a/src/client/pages/instance/index.metrics.vue b/src/client/pages/instance/metrics.vue similarity index 60% rename from src/client/pages/instance/index.metrics.vue rename to src/client/pages/instance/metrics.vue index 9dd115240..18cfe5eee 100644 --- a/src/client/pages/instance/index.metrics.vue +++ b/src/client/pages/instance/metrics.vue @@ -1,101 +1,52 @@ <template> -<div> - <MkFolder> - <template #header><i class="fas fa-heartbeat"></i> {{ $ts.metrics }}</template> - <div class="_section" style="padding: 0 var(--margin);"> - <div class="_content"> - <MkContainer :foldable="false" class="_gap"> - <template #header><i class="fas fa-microchip"></i>{{ $ts.cpuAndMemory }}</template> - <!-- - <template #func> - <button class="_button" @click="resume" :disabled="!paused"><i class="fas fa-play"></i></button> - <button class="_button" @click="pause" :disabled="paused"><i class="fas fa-pause"></i></button> - </template> - --> - - <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">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div> - <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> - <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> - </div> - </div> - </div> - </MkContainer> - - <MkContainer :foldable="false" class="_gap"> - <template #header><i class="fas fa-hdd"></i> {{ $ts.disk }}</template> - <!-- - <template #func> - <button class="_button" @click="resume" :disabled="!paused"><i class="fas fa-play"></i></button> - <button class="_button" @click="pause" :disabled="paused"><i class="fas fa-pause"></i></button> - </template> - --> - - <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>{{ bytes(serverInfo.fs.total) }}</div> - <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> - <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> - </div> - </div> - </div> - </MkContainer> - - <MkContainer :foldable="false" class="_gap"> - <template #header><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</template> - <!-- - <template #func> - <button class="_button" @click="resume" :disabled="!paused"><i class="fas fa-play"></i></button> - <button class="_button" @click="pause" :disabled="paused"><i class="fas fa-pause"></i></button> - </template> - --> - - <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> - </MkContainer> +<div class="_formItem"> + <div class="_formLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div> + <div class="_formPanel xhexznfu"> + <div> + <canvas :ref="cpumem"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div> + <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + </div> </div> </div> - </MkFolder> - - <MkFolder> - <template #header><i class="fas fa-clipboard-list"></i> {{ $ts.jobQueue }}</template> - - <div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }"> - <MkContainer :foldable="false" :scrollable="true" :resize-base-el="() => $el"> - <template #header><i class="fas fa-exclamation-triangle"></i> {{ $ts.delayed }}</template> - - <div class="_content"> - <div class="_keyValue" v-for="job in jobs" :key="job[0]"> - <button class="_button" @click="showInstanceInfo(job[0])">{{ job[0] }}</button> - <div style="text-align: right;">{{ number(job[1]) }} jobs</div> - </div> - </div> - </MkContainer> - <XQueue :connection="queueConnection" domain="inbox" ref="queue" class="queue"> - <template #title><i class="fas fa-exchange-alt"></i> In</template> - </XQueue> - <XQueue :connection="queueConnection" domain="deliver" class="queue"> - <template #title><i class="fas fa-exchange-alt"></i> Out</template> - </XQueue> + </div> +</div> +<div class="_formItem"> + <div class="_formLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div> + <div class="_formPanel xhexznfu"> + <div> + <canvas :ref="disk"></canvas> </div> - </MkFolder> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div> + <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_formItem"> + <div class="_formLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div> + <div class="_formPanel xhexznfu"> + <div> + <canvas :ref="net"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div> + </div> + </div> + </div> + </div> </div> </template> @@ -188,9 +139,11 @@ export default defineComponent({ }, beforeUnmount() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - this.connection.dispose(); + if (this.connection) { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + } this.queueConnection.dispose(); }, @@ -232,9 +185,9 @@ export default defineComponent({ aspectRatio: 3, layout: { padding: { - left: 0, - right: 0, - top: 8, + left: 16, + right: 16, + top: 16, bottom: 0 } }, @@ -304,9 +257,9 @@ export default defineComponent({ aspectRatio: 3, layout: { padding: { - left: 0, - right: 0, - top: 8, + left: 16, + right: 16, + top: 16, bottom: 0 } }, @@ -375,9 +328,9 @@ export default defineComponent({ aspectRatio: 3, layout: { padding: { - left: 0, - right: 0, - top: 8, + left: 16, + right: 16, + top: 16, bottom: 0 } }, @@ -494,81 +447,9 @@ export default defineComponent({ <style lang="scss" scoped> .xhexznfu { - &.min-width_1000px { - .sboqnrfi { - display: grid; - grid-template-columns: 3.2fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - - > .stats { - height: min-content; - } - - > .column { - display: flex; - flex-direction: column; - - > .info { - flex-shrink: 0; - flex-grow: 0; - } - - > .db { - flex: 1; - flex-grow: 0; - height: 100%; - } - - > .fed { - flex: 1; - flex-grow: 0; - height: 100%; - } - - > *:not(:last-child) { - margin-bottom: var(--margin); - } - } - } - - .segusily { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - padding: 0 16px; - } - - .vkyrmkwb { - display: grid; - grid-template-columns: 0.5fr 1fr 1fr; - grid-template-rows: 1fr; - gap: 16px 16px; - margin-bottom: var(--margin); - - > .queue { - height: min-content; - } - - > * { - margin-bottom: 0; - } - } - - .uwuemslx { - display: grid; - grid-template-columns: 2fr 3fr; - grid-template-rows: 1fr; - gap: 16px 16px; - height: 400px; - } - } - - .vkyrmkwb { - > * { - margin-bottom: var(--margin); - } + > div:nth-child(2) { + padding: 16px; + border-top: solid 0.5px var(--divider); } } </style> diff --git a/src/client/pages/instance/object-storage.vue b/src/client/pages/instance/object-storage.vue new file mode 100644 index 000000000..814aeb6e4 --- /dev/null +++ b/src/client/pages/instance/object-storage.vue @@ -0,0 +1,154 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model:value="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch> + + <template v-if="useObjectStorage"> + <FormInput v-model:value="objectStorageBaseUrl"> + <span>{{ $ts.objectStorageBaseUrl }}</span> + <template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template> + </FormInput> + + <FormInput v-model:value="objectStorageBucket"> + <span>{{ $ts.objectStorageBucket }}</span> + <template #desc>{{ $ts.objectStorageBucketDesc }}</template> + </FormInput> + + <FormInput v-model:value="objectStoragePrefix"> + <span>{{ $ts.objectStoragePrefix }}</span> + <template #desc>{{ $ts.objectStoragePrefixDesc }}</template> + </FormInput> + + <FormInput v-model:value="objectStorageEndpoint"> + <span>{{ $ts.objectStorageEndpoint }}</span> + <template #desc>{{ $ts.objectStorageEndpointDesc }}</template> + </FormInput> + + <FormInput v-model:value="objectStorageRegion"> + <span>{{ $ts.objectStorageRegion }}</span> + <template #desc>{{ $ts.objectStorageRegionDesc }}</template> + </FormInput> + + <FormInput v-model:value="objectStorageAccessKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>Access key</span> + </FormInput> + + <FormInput v-model:value="objectStorageSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>Secret key</span> + </FormInput> + + <FormSwitch v-model:value="objectStorageUseSSL"> + {{ $ts.objectStorageUseSSL }} + <template #desc>{{ $ts.objectStorageUseSSLDesc }}</template> + </FormSwitch> + + <FormSwitch v-model:value="objectStorageUseProxy"> + {{ $ts.objectStorageUseProxy }} + <template #desc>{{ $ts.objectStorageUseProxyDesc }}</template> + </FormSwitch> + + <FormSwitch v-model:value="objectStorageSetPublicRead"> + {{ $ts.objectStorageSetPublicRead }} + </FormSwitch> + + <FormSwitch v-model:value="objectStorageS3ForcePathStyle"> + s3ForcePathStyle + </FormSwitch> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.objectStorage, + icon: 'fas fa-cloud' + }, + useObjectStorage: false, + objectStorageBaseUrl: null, + objectStorageBucket: null, + objectStoragePrefix: null, + objectStorageEndpoint: null, + objectStorageRegion: null, + objectStoragePort: null, + objectStorageAccessKey: null, + objectStorageSecretKey: null, + objectStorageUseSSL: false, + objectStorageUseProxy: false, + objectStorageSetPublicRead: false, + objectStorageS3ForcePathStyle: true, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.useObjectStorage = meta.useObjectStorage; + this.objectStorageBaseUrl = meta.objectStorageBaseUrl; + this.objectStorageBucket = meta.objectStorageBucket; + this.objectStoragePrefix = meta.objectStoragePrefix; + this.objectStorageEndpoint = meta.objectStorageEndpoint; + this.objectStorageRegion = meta.objectStorageRegion; + this.objectStoragePort = meta.objectStoragePort; + this.objectStorageAccessKey = meta.objectStorageAccessKey; + this.objectStorageSecretKey = meta.objectStorageSecretKey; + this.objectStorageUseSSL = meta.objectStorageUseSSL; + this.objectStorageUseProxy = meta.objectStorageUseProxy; + this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead; + this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle; + }, + save() { + os.apiWithDialog('admin/update-meta', { + useObjectStorage: this.useObjectStorage, + objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null, + objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null, + objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null, + objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null, + objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null, + objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null, + objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null, + objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null, + objectStorageUseSSL: this.objectStorageUseSSL, + objectStorageUseProxy: this.objectStorageUseProxy, + objectStorageSetPublicRead: this.objectStorageSetPublicRead, + objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/other-settings.vue b/src/client/pages/instance/other-settings.vue new file mode 100644 index 000000000..b3954149a --- /dev/null +++ b/src/client/pages/instance/other-settings.vue @@ -0,0 +1,68 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormGroup> + <FormInput v-model:value="summalyProxy"> + <template #prefix><i class="fas fa-link"></i></template> + Summaly Proxy URL + </FormInput> + </FormGroup> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.other, + icon: 'fas fa-cogs' + }, + summalyProxy: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.summalyProxy = meta.summalyProxy; + }, + save() { + os.apiWithDialog('admin/update-meta', { + summalyProxy: this.summalyProxy, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/overview.vue b/src/client/pages/instance/overview.vue new file mode 100644 index 000000000..651ace08f --- /dev/null +++ b/src/client/pages/instance/overview.vue @@ -0,0 +1,131 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSuspense :p="fetchStats" v-slot="{ result: stats }"> + <FormGroup> + <FormKeyValueView> + <template #key>Users</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Notes</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> + + <div class="_formItem"> + <div class="_formPanel"> + <MkInstanceStats :chart-limit="300" :detailed="true"/> + </div> + </div> + + <XMetrics/> + + <FormSuspense :p="fetchServerInfo" v-slot="{ result: serverInfo }"> + <FormGroup> + <FormKeyValueView> + <template #key>Node.js</template> + <template #value>{{ serverInfo.node }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>PostgreSQL</template> + <template #value>{{ serverInfo.psql }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Redis</template> + <template #value>{{ serverInfo.redis }}</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw } from 'vue'; +import VueJsonPretty from 'vue-json-pretty'; +import FormKeyValueView from '@client/components/form/key-value-view.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import MkInstanceStats from '@client/components/instance-stats.vue'; +import MkButton from '@client/components/ui/button.vue'; +import MkSelect from '@client/components/ui/select.vue'; +import MkInput from '@client/components/ui/input.vue'; +import MkContainer from '@client/components/ui/container.vue'; +import MkFolder from '@client/components/ui/folder.vue'; +import { version, url } from '@client/config'; +import bytes from '../../filters/bytes'; +import number from '../../filters/number'; +import MkInstanceInfo from './instance.vue'; +import XMetrics from './metrics.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSuspense, + FormGroup, + FormKeyValueView, + MkInstanceStats, + MkButton, + MkSelect, + MkInput, + MkContainer, + MkFolder, + XMetrics, + VueJsonPretty, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.overview, + icon: 'fas fa-tachometer-alt' + }, + page: 'index', + version, + url, + stats: null, + fetchStats: () => os.api('stats', {}), + fetchServerInfo: () => os.api('admin/server-info', {}), + fetchJobs: () => os.api('admin/queue/deliver-delayed', {}), + fetchModLogs: () => os.api('admin/show-moderation-logs', {}), + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + this.meta = await os.api('meta', { detail: true }); + }, + + async showInstanceInfo(q) { + let instance = q; + if (typeof q === 'string') { + instance = await os.api('federation/show-instance', { + host: q + }); + } + os.popup(MkInstanceInfo, { + instance: instance + }, {}, 'closed'); + }, + + bytes, + + number, + } +}); +</script> diff --git a/src/client/pages/instance/proxy-account.vue b/src/client/pages/instance/proxy-account.vue new file mode 100644 index 000000000..3e2df8dcb --- /dev/null +++ b/src/client/pages/instance/proxy-account.vue @@ -0,0 +1,86 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.proxyAccount }}</template> + <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : $ts.none }}</template> + </FormKeyValueView> + <template #caption>{{ $ts.proxyAccountDescription }}</template> + </FormGroup> + + <FormButton @click="chooseProxyAccount" primary>{{ $ts.selectAccount }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormKeyValueView from '@client/components/form/key-value-view.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormKeyValueView, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.proxyAccount, + icon: 'fas fa-ghost' + }, + proxyAccount: null, + proxyAccountId: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.proxyAccountId = meta.proxyAccountId; + if (this.proxyAccountId) { + this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId }); + } + }, + + chooseProxyAccount() { + os.selectUser().then(user => { + this.proxyAccount = user; + this.proxyAccountId = user.id; + this.save(); + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + proxyAccountId: this.proxyAccountId, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/queue.chart.vue b/src/client/pages/instance/queue.chart.vue index 0eb70debf..446c97920 100644 --- a/src/client/pages/instance/queue.chart.vue +++ b/src/client/pages/instance/queue.chart.vue @@ -1,27 +1,29 @@ <template> -<section class="_section"> - <div class="_title"><slot name="title"></slot></div> - <div class="_content _table"> - <div class="_row"> - <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> - <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> - <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> - <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> - </div> - </div> - <div class="_content" style="margin-bottom: -8px;"> - <canvas ref="chart"></canvas> - </div> - <div class="_content" style="max-height: 180px; overflow: auto;"> - <div v-if="jobs.length > 0"> - <div v-for="job in jobs" :key="job[0]"> - <span>{{ job[0] }}</span> - <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> +<div class="_formItem"> + <div class="_formLabel"><slot name="title"></slot></div> + <div class="_formPanel pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> </div> </div> - <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> + <div class=""> + <canvas ref="chart"></canvas> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> + </div> + </div> + <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> + </div> </div> -</section> +</div> </template> <script lang="ts"> @@ -110,10 +112,10 @@ export default defineComponent({ aspectRatio: 3, layout: { padding: { - left: 0, - right: 0, - top: 8, - bottom: 0 + left: 16, + right: 16, + top: 16, + bottom: 12 } }, legend: { @@ -198,3 +200,19 @@ export default defineComponent({ } }); </script> + +<style lang="scss" scoped> +.pumxzjhg { + > .status { + padding: 16px; + border-bottom: solid 0.5px var(--divider); + } + + > .jobs { + padding: 16px; + border-top: solid 0.5px var(--divider); + max-height: 180px; + overflow: auto; + } +} +</style> diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/instance/queue.vue index 0c1e0e51b..2dccf48d3 100644 --- a/src/client/pages/instance/queue.vue +++ b/src/client/pages/instance/queue.vue @@ -1,43 +1,47 @@ <template> -<div> +<FormBase> <XQueue :connection="connection" domain="inbox"> - <template #title><i class="fas fa-exchange-alt"></i> In</template> + <template #title>In</template> </XQueue> <XQueue :connection="connection" domain="deliver"> - <template #title><i class="fas fa-exchange-alt"></i> Out</template> + <template #title>Out</template> </XQueue> - <section class="_section"> - <div class="_content"> - <MkButton @click="clear()"><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</MkButton> - </div> - </section> -</div> + <FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import MkButton from '@client/components/ui/button.vue'; import XQueue from './queue.chart.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormButton from '@client/components/form/button.vue'; import * as os from '@client/os'; import * as symbols from '@client/symbols'; export default defineComponent({ components: { + FormBase, + FormButton, MkButton, XQueue, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { title: this.$ts.jobQueue, - icon: 'fas fa-exchange-alt', + icon: 'fas fa-clipboard-list', }, connection: os.stream.useSharedConnection('queueStats'), } }, mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + this.$nextTick(() => { this.connection.send('requestLog', { id: Math.random().toString().substr(2, 8), diff --git a/src/client/pages/instance/relays.vue b/src/client/pages/instance/relays.vue index dbf75ea53..a3e4e7d1d 100644 --- a/src/client/pages/instance/relays.vue +++ b/src/client/pages/instance/relays.vue @@ -1,44 +1,41 @@ <template> -<div class="relaycxt"> - <section class="_section add"> - <div class="_title"><i class="fas fa-plus"></i> {{ $ts.addRelay }}</div> - <div class="_content"> - <MkInput v-model:value="inbox"> - <span>{{ $ts.inboxUrl }}</span> - </MkInput> - <MkButton @click="add(inbox)" primary><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> - </div> - </section> +<FormBase class="relaycxt"> + <FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton> - <section class="_section relays"> - <div class="_title"><i class="fas fa-project-diagram"></i> {{ $ts.addedRelays }}</div> - <div class="_content relay" v-for="relay in relays" :key="relay.inbox"> + <div class="_formItem" v-for="relay in relays" :key="relay.inbox"> + <div class="_formPanel" style="padding: 16px;"> <div>{{ relay.inbox }}</div> <div>{{ $t(`_relayStatus.${relay.status}`) }}</div> - <MkButton class="button" inline @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> </div> - </section> -</div> + </div> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import MkButton from '@client/components/ui/button.vue'; import MkInput from '@client/components/ui/input.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormButton from '@client/components/form/button.vue'; import * as os from '@client/os'; import * as symbols from '@client/symbols'; export default defineComponent({ components: { + FormBase, + FormButton, MkButton, MkInput, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { title: this.$ts.relays, - icon: 'fas fa-project-diagram', + icon: 'fas fa-globe', }, relays: [], inbox: '', @@ -49,8 +46,19 @@ export default defineComponent({ this.refresh(); }, + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + methods: { - add(inbox: string) { + async addRelay() { + const { canceled, result: inbox } = await os.dialog({ + title: this.$ts.addRelay, + input: { + placeholder: this.$ts.inboxUrl + } + }); + if (canceled) return; os.api('admin/relays/add', { inbox }).then((relay: any) => { @@ -86,9 +94,5 @@ export default defineComponent({ </script> <style lang="scss" scoped> -._content.relay { - div { - margin: 0.5em 0; - } -} + </style> diff --git a/src/client/pages/instance/security.vue b/src/client/pages/instance/security.vue new file mode 100644 index 000000000..e3397a113 --- /dev/null +++ b/src/client/pages/instance/security.vue @@ -0,0 +1,77 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormLink to="/instance/bot-protection"> + <i class="fas fa-shield-alt"></i> {{ $ts.botProtection }} + <template #suffix v-if="enableHcaptcha">hCaptcha</template> + <template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template> + <template #suffix v-else>{{ $ts.none }} ({{ $ts.notRecommended }})</template> + </FormLink> + + <FormSwitch v-model:value="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormLink from '@client/components/form/link.vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormLink, + FormSwitch, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.security, + icon: 'fas fa-lock' + }, + enableHcaptcha: false, + enableRecaptcha: false, + enableRegistration: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableHcaptcha = meta.enableHcaptcha; + this.enableRecaptcha = meta.enableRecaptcha; + this.enableRegistration = !meta.disableRegistration; + }, + + save() { + os.apiWithDialog('admin/update-meta', { + disableRegistration: !this.enableRegistration, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/service-worker.vue b/src/client/pages/instance/service-worker.vue new file mode 100644 index 000000000..a52932bb7 --- /dev/null +++ b/src/client/pages/instance/service-worker.vue @@ -0,0 +1,84 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model:value="enableServiceWorker"> + {{ $ts.enableServiceworker }} + <template #desc>{{ $ts.serviceworkerInfo }}</template> + </FormSwitch> + + <template v-if="enableServiceWorker"> + <FormInput v-model:value="swPublicKey"> + <template #prefix><i class="fas fa-key"></i></template> + Public key + </FormInput> + + <FormInput v-model:value="swPrivateKey"> + <template #prefix><i class="fas fa-key"></i></template> + Private key + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'ServiceWorker', + icon: 'fas fa-bolt' + }, + enableServiceWorker: false, + swPublicKey: null, + swPrivateKey: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableServiceWorker = meta.enableServiceWorker; + this.swPublicKey = meta.swPublickey; + this.swPrivateKey = meta.swPrivateKey; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableServiceWorker: this.enableServiceWorker, + swPublicKey: this.swPublicKey, + swPrivateKey: this.swPrivateKey, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue index b827a7764..66f01c42c 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/instance/settings.vue @@ -1,581 +1,132 @@ <template> -<div v-if="meta" class="_section"> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-info-circle"></i> {{ $ts.basicInfo }}</div> - <div class="_content"> - <MkInput v-model:value="name">{{ $ts.instanceName }}</MkInput> - <MkTextarea v-model:value="description">{{ $ts.instanceDescription }}</MkTextarea> - <MkInput v-model:value="iconUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.iconUrl }}</MkInput> - <MkInput v-model:value="bannerUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.bannerUrl }}</MkInput> - <MkInput v-model:value="backgroundImageUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.backgroundImageUrl }}</MkInput> - <MkInput v-model:value="logoImageUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.logoImageUrl }}</MkInput> - <MkInput v-model:value="tosUrl"><template #icon><i class="fas fa-link"></i></template>{{ $ts.tosUrl }}</MkInput> - <MkInput v-model:value="maintainerName">{{ $ts.maintainerName }}</MkInput> - <MkInput v-model:value="maintainerEmail" type="email"><template #icon><i class="fas fa-envelope"></i></template>{{ $ts.maintainerEmail }}</MkInput> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> +<FormBase> + <FormSuspense :p="init"> + <FormInput v-model:value="name"> + <span>{{ $ts.instanceName }}</span> + </FormInput> - <MkInput v-model:value="pinnedClipId">{{ $ts.pinnedClipId }}</MkInput> + <FormTextarea v-model:value="description"> + <span>{{ $ts.instanceDescription }}</span> + </FormTextarea> - <section class="_card _gap"> - <div class="_content"> - <MkInput v-model:value="maxNoteTextLength" type="number" :save="() => save()"><template #icon><i class="fas fa-pencil-alt"></i></template>{{ $ts.maxNoteTextLength }}</MkInput> - </div> - <div class="_content"> - <MkSwitch v-model:value="enableLocalTimeline" @update:value="save()">{{ $ts.enableLocalTimeline }}</MkSwitch> - <MkSwitch v-model:value="enableGlobalTimeline" @update:value="save()">{{ $ts.enableGlobalTimeline }}</MkSwitch> - <MkInfo>{{ $ts.disablingTimelinesInfo }}</MkInfo> - </div> - <div class="_content"> - <MkSwitch v-model:value="useStarForReactionFallback" @update:value="save()">{{ $ts.useStarForReactionFallback }}</MkSwitch> - </div> - </section> + <FormInput v-model:value="iconUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.iconUrl }}</span> + </FormInput> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-user"></i> {{ $ts.registration }}</div> - <div class="_content"> - <MkSwitch v-model:value="enableRegistration" @update:value="save()">{{ $ts.enableRegistration }}</MkSwitch> - <MkButton v-if="!enableRegistration" @click="invite">{{ $ts.invite }}</MkButton> - </div> - </section> + <FormInput v-model:value="bannerUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.bannerUrl }}</span> + </FormInput> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-shield-alt"></i> {{ $ts.hcaptcha }}</div> - <div class="_content"> - <MkSwitch v-model:value="enableHcaptcha">{{ $ts.enableHcaptcha }}</MkSwitch> - <template v-if="enableHcaptcha"> - <MkInput v-model:value="hcaptchaSiteKey" :disabled="!enableHcaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.hcaptchaSiteKey }}</MkInput> - <MkInput v-model:value="hcaptchaSecretKey" :disabled="!enableHcaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.hcaptchaSecretKey }}</MkInput> - </template> - </div> - <div class="_content" v-if="enableHcaptcha"> - <header>{{ $ts.preview }}</header> - <captcha v-if="enableHcaptcha" provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> + <FormInput v-model:value="tosUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.tosUrl }}</span> + </FormInput> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-shield-alt"></i> {{ $ts.recaptcha }}</div> - <div class="_content"> - <MkSwitch v-model:value="enableRecaptcha" ref="enableRecaptcha">{{ $ts.enableRecaptcha }}</MkSwitch> - <template v-if="enableRecaptcha"> - <MkInput v-model:value="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.recaptchaSiteKey }}</MkInput> - <MkInput v-model:value="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><i class="fas fa-key"></i></template>{{ $ts.recaptchaSecretKey }}</MkInput> - </template> - </div> - <div class="_content" v-if="enableRecaptcha && recaptchaSiteKey"> - <header>{{ $ts.preview }}</header> - <captcha v-if="enableRecaptcha" provider="grecaptcha" :sitekey="recaptchaSiteKey"/> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> + <FormInput v-model:value="maintainerName"> + <span>{{ $ts.maintainerName }}</span> + </FormInput> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-envelope"></i> {{ $ts.emailConfig }}</div> - <div class="_content"> - <MkSwitch v-model:value="enableEmail" @update:value="save()">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></MkSwitch> - <MkInput v-model:value="email" type="email" :disabled="!enableEmail">{{ $ts.email }}</MkInput> - <div><b>{{ $ts.smtpConfig }}</b></div> - <div class="_inputs"> - <MkInput v-model:value="smtpHost" :disabled="!enableEmail">{{ $ts.smtpHost }}</MkInput> - <MkInput v-model:value="smtpPort" type="number" :disabled="!enableEmail">{{ $ts.smtpPort }}</MkInput> - </div> - <div class="_inputs"> - <MkInput v-model:value="smtpUser" :disabled="!enableEmail">{{ $ts.smtpUser }}</MkInput> - <MkInput v-model:value="smtpPass" type="password" :disabled="!enableEmail">{{ $ts.smtpPass }}</MkInput> - </div> - <MkInfo>{{ $ts.emptyToDisableSmtpAuth }}</MkInfo> - <MkSwitch v-model:value="smtpSecure" :disabled="!enableEmail">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></MkSwitch> - <div> - <MkButton :disabled="!enableEmail" primary inline @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - <MkButton :disabled="!enableEmail" inline @click="testEmail()">{{ $ts.testEmail }}</MkButton> - </div> - </div> - </section> + <FormInput v-model:value="maintainerEmail" type="email"> + <template #prefix><i class="fas fa-envelope"></i></template> + <span>{{ $ts.maintainerEmail }}</span> + </FormInput> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-bolt"></i> {{ $ts.serviceworker }}</div> - <div class="_content"> - <MkSwitch v-model:value="enableServiceWorker">{{ $ts.enableServiceworker }}<template #desc>{{ $ts.serviceworkerInfo }}</template></MkSwitch> - <template v-if="enableServiceWorker"> - <div class="_inputs"> - <MkInput v-model:value="swPublicKey" :disabled="!enableServiceWorker"><template #icon><i class="fas fa-key"></i></template>Public key</MkInput> - <MkInput v-model:value="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><i class="fas fa-key"></i></template>Private key</MkInput> - </div> - </template> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> + <FormInput v-model:value="maxNoteTextLength" type="number"> + <template #prefix><i class="fas fa-pencil-alt"></i></template> + <span>{{ $ts.maxNoteTextLength }}</span> + </FormInput> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedUsers }}</div> - <div class="_content"> - <MkTextarea v-model:value="pinnedUsers"> - <template #desc>{{ $ts.pinnedUsersDescription }} <button class="_textButton" @click="addPinUser">{{ $ts.addUser }}</button></template> - </MkTextarea> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> + <FormSwitch v-model:value="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch> + <FormSwitch v-model:value="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch> + <FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo> - <section class="_card _gap"> - <div class="_title"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedPages }}</div> - <div class="_content"> - <MkTextarea v-model:value="pinnedPages"> - <template #desc>{{ $ts.pinnedPagesDescription }}</template> - </MkTextarea> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> - - <section class="_card _gap"> - <div class="_title"><i class="fas fa-cloud"></i> {{ $ts.files }}</div> - <div class="_content"> - <MkSwitch v-model:value="cacheRemoteFiles">{{ $ts.cacheRemoteFiles }}<template #desc>{{ $ts.cacheRemoteFilesDescription }}</template></MkSwitch> - <MkSwitch v-model:value="proxyRemoteFiles">{{ $ts.proxyRemoteFiles }}<template #desc>{{ $ts.proxyRemoteFilesDescription }}</template></MkSwitch> - <MkInput v-model:value="localDriveCapacityMb" type="number">{{ $ts.driveCapacityPerLocalAccount }}<template #suffix>MB</template><template #desc>{{ $ts.inMb }}</template></MkInput> - <MkInput v-model:value="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">{{ $ts.driveCapacityPerRemoteAccount }}<template #suffix>MB</template><template #desc>{{ $ts.inMb }}</template></MkInput> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> - - <section class="_card _gap"> - <div class="_title"><i class="fas fa-cloud"></i> {{ $ts.objectStorage }}</div> - <div class="_content"> - <MkSwitch v-model:value="useObjectStorage">{{ $ts.useObjectStorage }}</MkSwitch> - <template v-if="useObjectStorage"> - <MkInput v-model:value="objectStorageBaseUrl" :disabled="!useObjectStorage">{{ $ts.objectStorageBaseUrl }}<template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template></MkInput> - <div class="_inputs"> - <MkInput v-model:value="objectStorageBucket" :disabled="!useObjectStorage">{{ $ts.objectStorageBucket }}<template #desc>{{ $ts.objectStorageBucketDesc }}</template></MkInput> - <MkInput v-model:value="objectStoragePrefix" :disabled="!useObjectStorage">{{ $ts.objectStoragePrefix }}<template #desc>{{ $ts.objectStoragePrefixDesc }}</template></MkInput> - </div> - <MkInput v-model:value="objectStorageEndpoint" :disabled="!useObjectStorage">{{ $ts.objectStorageEndpoint }}<template #desc>{{ $ts.objectStorageEndpointDesc }}</template></MkInput> - <div class="_inputs"> - <MkInput v-model:value="objectStorageRegion" :disabled="!useObjectStorage">{{ $ts.objectStorageRegion }}<template #desc>{{ $ts.objectStorageRegionDesc }}</template></MkInput> - </div> - <div class="_inputs"> - <MkInput v-model:value="objectStorageAccessKey" :disabled="!useObjectStorage"><template #icon><i class="fas fa-key"></i></template>Access key</MkInput> - <MkInput v-model:value="objectStorageSecretKey" :disabled="!useObjectStorage"><template #icon><i class="fas fa-key"></i></template>Secret key</MkInput> - </div> - <MkSwitch v-model:value="objectStorageUseSSL" :disabled="!useObjectStorage">{{ $ts.objectStorageUseSSL }}<template #desc>{{ $ts.objectStorageUseSSLDesc }}</template></MkSwitch> - <MkSwitch v-model:value="objectStorageUseProxy" :disabled="!useObjectStorage">{{ $ts.objectStorageUseProxy }}<template #desc>{{ $ts.objectStorageUseProxyDesc }}</template></MkSwitch> - <MkSwitch v-model:value="objectStorageSetPublicRead" :disabled="!useObjectStorage">{{ $ts.objectStorageSetPublicRead }}</MkSwitch> - <MkSwitch v-model:value="objectStorageS3ForcePathStyle" :disabled="!useObjectStorage">s3ForcePathStyle</MkSwitch> - </template> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> - - <section class="_card _gap"> - <div class="_title"><i class="fas fa-ghost"></i> {{ $ts.proxyAccount }}</div> - <div class="_content"> - <MkInput :value="proxyAccount ? proxyAccount.username : null" disabled><template #prefix>@</template>{{ $ts.proxyAccount }}<template #desc>{{ $ts.proxyAccountDescription }}</template></MkInput> - <MkButton primary @click="chooseProxyAccount">{{ $ts.chooseProxyAccount }}</MkButton> - </div> - </section> - - <section class="_card _gap"> - <div class="_title"><i class="fas fa-ban"></i> {{ $ts.blockedInstances }}</div> - <div class="_content"> - <MkTextarea v-model:value="blockedHosts"> - <template #desc>{{ $ts.blockedInstancesDescription }}</template> - </MkTextarea> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> - - <section class="_card _gap"> - <div class="_title"><i class="fas fa-share-alt"></i> {{ $ts.integration }}</div> - <div class="_content"> - <header><i class="fab fa-twitter"></i> Twitter</header> - <MkSwitch v-model:value="enableTwitterIntegration">{{ $ts.enable }}</MkSwitch> - <template v-if="enableTwitterIntegration"> - <MkInfo>Callback URL: {{ `${url}/api/tw/cb` }}</MkInfo> - <MkInput v-model:value="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><i class="fas fa-key"></i></template>Consumer Key</MkInput> - <MkInput v-model:value="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><i class="fas fa-key"></i></template>Consumer Secret</MkInput> - </template> - </div> - <div class="_content"> - <header><i class="fas fa-github"></i> GitHub</header> - <MkSwitch v-model:value="enableGithubIntegration">{{ $ts.enable }}</MkSwitch> - <template v-if="enableGithubIntegration"> - <MkInfo>Callback URL: {{ `${url}/api/gh/cb` }}</MkInfo> - <MkInput v-model:value="githubClientId" :disabled="!enableGithubIntegration"><template #icon><i class="fas fa-key"></i></template>Client ID</MkInput> - <MkInput v-model:value="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><i class="fas fa-key"></i></template>Client Secret</MkInput> - </template> - </div> - <div class="_content"> - <header><i class="fas fa-discord"></i> Discord</header> - <MkSwitch v-model:value="enableDiscordIntegration">{{ $ts.enable }}</MkSwitch> - <template v-if="enableDiscordIntegration"> - <MkInfo>Callback URL: {{ `${url}/api/dc/cb` }}</MkInfo> - <MkInput v-model:value="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><i class="fas fa-key"></i></template>Client ID</MkInput> - <MkInput v-model:value="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><i class="fas fa-key"></i></template>Client Secret</MkInput> - </template> - </div> - <div class="_footer"> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> - - <section class="_card _gap"> - <div class="_title"><i class="fas fa-archway"></i> Summaly Proxy</div> - <div class="_content"> - <MkInput v-model:value="summalyProxy">URL</MkInput> - <MkButton primary @click="save(true)"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - </div> - </section> -</div> + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> </template> <script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/ui/input.vue'; -import MkTextarea from '@client/components/ui/textarea.vue'; -import MkSwitch from '@client/components/ui/switch.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import { url } from '@client/config'; -import getAcct from '@/misc/acct/render'; +import { defineComponent } from 'vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormInput from '@client/components/form/input.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormTextarea from '@client/components/form/textarea.vue'; +import FormInfo from '@client/components/form/info.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; import * as os from '@client/os'; -import { fetchInstance } from '@client/instance'; import * as symbols from '@client/symbols'; +import { fetchInstance } from '@client/instance'; export default defineComponent({ components: { - MkButton, - MkInput, - MkTextarea, - MkSwitch, - MkInfo, - Captcha: defineAsyncComponent(() => import('@client/components/captcha.vue')), + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { - title: this.$ts.instance, - icon: 'fas fa-cog', + title: this.$ts.general, + icon: 'fas fa-cog' }, - meta: null, - url, - proxyAccount: null, - proxyAccountId: null, - cacheRemoteFiles: false, - proxyRemoteFiles: false, - localDriveCapacityMb: 0, - remoteDriveCapacityMb: 0, - blockedHosts: '', - pinnedUsers: '', - pinnedPages: '', - pinnedClipId: null, - maintainerName: null, - maintainerEmail: null, name: null, description: null, tosUrl: null as string | null, - enableEmail: false, - email: null, - bannerUrl: null, + maintainerName: null, + maintainerEmail: null, iconUrl: null, - logoImageUrl: null, - backgroundImageUrl: null, + bannerUrl: null, maxNoteTextLength: 0, - enableRegistration: false, enableLocalTimeline: false, enableGlobalTimeline: false, - enableHcaptcha: false, - hcaptchaSiteKey: null, - hcaptchaSecretKey: null, - enableRecaptcha: false, - recaptchaSiteKey: null, - recaptchaSecretKey: null, - enableServiceWorker: false, - swPublicKey: null, - swPrivateKey: null, - useObjectStorage: false, - objectStorageBaseUrl: null, - objectStorageBucket: null, - objectStoragePrefix: null, - objectStorageEndpoint: null, - objectStorageRegion: null, - objectStoragePort: null, - objectStorageAccessKey: null, - objectStorageSecretKey: null, - objectStorageUseSSL: false, - objectStorageUseProxy: false, - objectStorageSetPublicRead: false, - objectStorageS3ForcePathStyle: true, - enableTwitterIntegration: false, - twitterConsumerKey: null, - twitterConsumerSecret: null, - enableGithubIntegration: false, - githubClientId: null, - githubClientSecret: null, - enableDiscordIntegration: false, - discordClientId: null, - discordClientSecret: null, - useStarForReactionFallback: false, - smtpSecure: false, - smtpHost: '', - smtpPort: 0, - smtpUser: '', - smtpPass: '', - summalyProxy: '', } }, - async created() { - this.meta = await os.api('meta', { detail: true }); - - 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.logoImageUrl = this.meta.logoImageUrl; - this.backgroundImageUrl = this.meta.backgroundImageUrl; - this.enableEmail = this.meta.enableEmail; - this.email = this.meta.email; - this.maintainerName = this.meta.maintainerName; - this.maintainerEmail = this.meta.maintainerEmail; - this.maxNoteTextLength = this.meta.maxNoteTextLength; - this.enableRegistration = !this.meta.disableRegistration; - this.enableLocalTimeline = !this.meta.disableLocalTimeline; - this.enableGlobalTimeline = !this.meta.disableGlobalTimeline; - this.enableHcaptcha = this.meta.enableHcaptcha; - this.hcaptchaSiteKey = this.meta.hcaptchaSiteKey; - this.hcaptchaSecretKey = this.meta.hcaptchaSecretKey; - this.enableRecaptcha = this.meta.enableRecaptcha; - this.recaptchaSiteKey = this.meta.recaptchaSiteKey; - this.recaptchaSecretKey = this.meta.recaptchaSecretKey; - this.proxyAccountId = this.meta.proxyAccountId; - 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.pinnedPages = this.meta.pinnedPages.join('\n'); - this.pinnedClipId = this.meta.pinnedClipId; - this.enableServiceWorker = this.meta.enableServiceWorker; - this.swPublicKey = this.meta.swPublickey; - this.swPrivateKey = this.meta.swPrivateKey; - this.useObjectStorage = this.meta.useObjectStorage; - this.objectStorageBaseUrl = this.meta.objectStorageBaseUrl; - this.objectStorageBucket = this.meta.objectStorageBucket; - this.objectStoragePrefix = this.meta.objectStoragePrefix; - this.objectStorageEndpoint = this.meta.objectStorageEndpoint; - this.objectStorageRegion = this.meta.objectStorageRegion; - this.objectStoragePort = this.meta.objectStoragePort; - this.objectStorageAccessKey = this.meta.objectStorageAccessKey; - this.objectStorageSecretKey = this.meta.objectStorageSecretKey; - this.objectStorageUseSSL = this.meta.objectStorageUseSSL; - this.objectStorageUseProxy = this.meta.objectStorageUseProxy; - this.objectStorageSetPublicRead = this.meta.objectStorageSetPublicRead; - this.objectStorageS3ForcePathStyle = this.meta.objectStorageS3ForcePathStyle; - 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.useStarForReactionFallback = this.meta.useStarForReactionFallback; - this.smtpSecure = this.meta.smtpSecure; - this.smtpHost = this.meta.smtpHost; - this.smtpPort = this.meta.smtpPort; - this.smtpUser = this.meta.smtpUser; - this.smtpPass = this.meta.smtpPass; - this.summalyProxy = this.meta.summalyProxy; - - if (this.proxyAccountId) { - os.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => { - this.proxyAccount = proxyAccount; - }); - } - }, - - mounted() { - this.$watch('enableHcaptcha', () => { - if (this.enableHcaptcha && this.enableRecaptcha) { - os.dialog({ - type: 'question', // warning だと間違って cancel するかもしれない - showCancelButton: true, - title: this.$ts.settingGuide, - text: this.$ts.avoidMultiCaptchaConfirm, - }).then(({ canceled }) => { - if (canceled) { - return; - } - - this.enableRecaptcha = false; - }); - } - }); - - this.$watch('enableRecaptcha', () => { - if (this.enableRecaptcha && this.enableHcaptcha) { - os.dialog({ - type: 'question', // warning だと間違って cancel するかもしれない - showCancelButton: true, - title: this.$ts.settingGuide, - text: this.$ts.avoidMultiCaptchaConfirm, - }).then(({ canceled }) => { - if (canceled) { - return; - } - - this.enableHcaptcha = false; - }); - } - }); + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); }, methods: { - invite() { - os.api('admin/invite').then(x => { - os.dialog({ - type: 'info', - text: x.code - }); - }).catch(e => { - os.dialog({ - type: 'error', - text: e - }); - }); + async init() { + const meta = await os.api('meta', { detail: true }); + this.name = meta.name; + this.description = meta.description; + this.tosUrl = meta.tosUrl; + this.iconUrl = meta.iconUrl; + this.bannerUrl = meta.bannerUrl; + this.maintainerName = meta.maintainerName; + this.maintainerEmail = meta.maintainerEmail; + this.maxNoteTextLength = meta.maxNoteTextLength; + this.enableLocalTimeline = !meta.disableLocalTimeline; + this.enableGlobalTimeline = !meta.disableGlobalTimeline; }, - addPinUser() { - os.selectUser().then(user => { - this.pinnedUsers = this.pinnedUsers.trim(); - this.pinnedUsers += '\n@' + getAcct(user); - this.pinnedUsers = this.pinnedUsers.trim(); - }); - }, - - chooseProxyAccount() { - os.selectUser().then(user => { - this.proxyAccount = user; - this.proxyAccountId = user.id; - this.save(true); - }); - }, - - async testEmail() { - os.api('admin/send-email', { - to: this.maintainerEmail, - subject: 'Test email', - text: 'Yo' - }).then(x => { - os.dialog({ - type: 'success', - splash: true - }); - }).catch(e => { - os.dialog({ - type: 'error', - text: e - }); - }); - }, - - save(withDialog = false) { - os.api('admin/update-meta', { + save() { + os.apiWithDialog('admin/update-meta', { name: this.name, description: this.description, tosUrl: this.tosUrl, - bannerUrl: this.bannerUrl, iconUrl: this.iconUrl, - logoImageUrl: this.logoImageUrl, - backgroundImageUrl: this.backgroundImageUrl, + bannerUrl: this.bannerUrl, maintainerName: this.maintainerName, maintainerEmail: this.maintainerEmail, maxNoteTextLength: this.maxNoteTextLength, - disableRegistration: !this.enableRegistration, disableLocalTimeline: !this.enableLocalTimeline, disableGlobalTimeline: !this.enableGlobalTimeline, - enableHcaptcha: this.enableHcaptcha, - hcaptchaSiteKey: this.hcaptchaSiteKey, - hcaptchaSecretKey: this.hcaptchaSecretKey, - enableRecaptcha: this.enableRecaptcha, - recaptchaSiteKey: this.recaptchaSiteKey, - recaptchaSecretKey: this.recaptchaSecretKey, - proxyAccountId: this.proxyAccountId, - 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') : [], - pinnedPages: this.pinnedPages ? this.pinnedPages.split('\n') : [], - pinnedClipId: (this.pinnedClipId && this.pinnedClipId) != '' ? this.pinnedClipId : null, - enableServiceWorker: this.enableServiceWorker, - swPublicKey: this.swPublicKey, - swPrivateKey: this.swPrivateKey, - useObjectStorage: this.useObjectStorage, - objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null, - objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null, - objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null, - objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null, - objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null, - objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null, - objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null, - objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null, - objectStorageUseSSL: this.objectStorageUseSSL, - objectStorageUseProxy: this.objectStorageUseProxy, - objectStorageSetPublicRead: this.objectStorageSetPublicRead, - objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle, - 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, - enableEmail: this.enableEmail, - email: this.email, - smtpSecure: this.smtpSecure, - smtpHost: this.smtpHost, - smtpPort: this.smtpPort, - smtpUser: this.smtpUser, - smtpPass: this.smtpPass, - summalyProxy: this.summalyProxy, - useStarForReactionFallback: this.useStarForReactionFallback, }).then(() => { fetchInstance(); - if (withDialog) { - os.success(); - } - }).catch(e => { - os.dialog({ - type: 'error', - text: e - }); }); } } diff --git a/src/client/pages/instance/user-dialog.vue b/src/client/pages/instance/user-dialog.vue deleted file mode 100644 index d7d627191..000000000 --- a/src/client/pages/instance/user-dialog.vue +++ /dev/null @@ -1,230 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="370" - @close="$refs.dialog.close()" - @closed="$emit('closed')" -> - <template #header v-if="user"><MkUserName class="name" :user="user"/></template> - <div class="vrcsvlkm" v-if="user && info"> - <div class="_section"> - <div class="banner" :style="bannerStyle"> - <MkAvatar class="avatar" :user="user" :show-indicator="true"/> - </div> - </div> - <div class="_section"> - <div class="title"> - <span class="acct">@{{ acct(user) }}</span> - </div> - <div class="status"> - <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span> - <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span> - <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span> - <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span> - </div> - </div> - <div class="_section"> - <div class="_content"> - <MkSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $ts.moderator }}</MkSwitch> - <MkSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $ts.silence }}</MkSwitch> - <MkSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $ts.suspend }}</MkSwitch> - </div> - </div> - <div class="_section"> - <div class="_content"> - <MkButton full @click="openProfile"><i class="fas fa-external-link-square-alt"></i> {{ $ts.profile }}</MkButton> - <MkButton full v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</MkButton> - <MkButton full @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</MkButton> - <MkButton full @click="deleteAllFiles" danger><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton> - </div> - </div> - <div class="_section"> - <details class="_content rawdata"> - <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> - </details> - </div> - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkSwitch from '@client/components/ui/switch.vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import Progress from '@client/scripts/loading'; -import { acct, userPage } from '../../filters/user'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkButton, - MkSwitch, - XModalWindow, - }, - - props: { - userId: { - required: true, - } - }, - - emits: ['closed'], - - data() { - return { - user: null, - info: null, - moderator: false, - silenced: false, - suspended: false, - }; - }, - - computed: { - bannerStyle(): any { - if (this.user.bannerUrl == null) return {}; - return { - backgroundImage: `url(${ this.user.bannerUrl })` - }; - }, - }, - - created() { - this.fetch(); - }, - - methods: { - async fetch() { - Progress.start(); - this.user = await os.api('users/show', { userId: this.userId }); - this.info = await os.api('admin/show-user', { userId: this.userId }); - this.moderator = this.info.isModerator; - this.silenced = this.info.isSilenced; - this.suspended = this.info.isSuspended; - Progress.done(); - }, - - /** 処理対象ユーザーの情報を更新する */ - async refreshUser() { - this.user = await os.api('users/show', { userId: this.user.id }); - this.info = await os.api('admin/show-user', { userId: this.user.id }); - }, - - openProfile() { - window.open(userPage(this.user, null, true), '_blank'); - }, - - async updateRemoteUser() { - await os.api('admin/update-remote-user', { userId: this.user.id }).then(res => { - os.success(); - }); - await this.refreshUser(); - }, - - async resetPassword() { - os.apiWithDialog('admin/reset-password', { - userId: this.user.id, - }, undefined, ({ password }) => { - os.dialog({ - type: 'success', - text: this.$t('newPasswordIs', { password }) - }); - }); - }, - - async toggleSilence(v) { - const confirm = await os.dialog({ - type: 'warning', - showCancelButton: true, - text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm, - }); - if (confirm.canceled) { - this.silenced = !v; - } else { - await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, - - async toggleSuspend(v) { - const confirm = await os.dialog({ - type: 'warning', - showCancelButton: true, - text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm, - }); - if (confirm.canceled) { - this.suspended = !v; - } else { - await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, - - async toggleModerator(v) { - await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); - await this.refreshUser(); - }, - - async deleteAllFiles() { - const confirm = await os.dialog({ - type: 'warning', - showCancelButton: true, - text: this.$ts.deleteAllFilesConfirm, - }); - if (confirm.canceled) return; - const process = async () => { - await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); - os.success(); - }; - await process().catch(e => { - os.dialog({ - type: 'error', - text: e.toString() - }); - }); - await this.refreshUser(); - }, - - acct - } -}); -</script> - -<style lang="scss" scoped> -.vrcsvlkm { - > ._section { - > .banner { - position: relative; - height: 100px; - background-color: #4c5e6d; - background-size: cover; - background-position: center; - border-radius: 8px; - - > .avatar { - position: absolute; - top: 60px; - width: 64px; - height: 64px; - left: 0; - right: 0; - margin: 0 auto; - border: solid 4px var(--panel); - } - } - - > .title { - text-align: center; - } - - > .status { - text-align: center; - margin-top: 8px; - } - - > .rawdata { - overflow: auto; - } - } -} -</style> diff --git a/src/client/pages/instance/user.vue b/src/client/pages/instance/user.vue new file mode 100644 index 000000000..fbc10a367 --- /dev/null +++ b/src/client/pages/instance/user.vue @@ -0,0 +1,229 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <div class="_formItem aeakzknw"> + <MkAvatar class="avatar" :user="user" :show-indicator="true"/> + </div> + + <FormLink :to="userPage(user)">Profile</FormLink> + + <FormGroup> + <FormKeyValueView> + <template #key>Acct</template> + <template #value><span class="_monospace">{{ acct(user) }}</span></template> + </FormKeyValueView> + + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $ts.moderator }}</FormSwitch> + <FormSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $ts.silence }}</FormSwitch> + <FormSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $ts.suspend }}</FormSwitch> + </FormGroup> + + <FormGroup> + <FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> + <FormButton v-if="user.host == null" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> + </FormGroup> + + <FormGroup> + <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> + + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + + <FormObjectView tall :value="user"> + <span>Raw</span> + </FormObjectView> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent } from 'vue'; +import FormObjectView from '@client/components/form/object-view.vue'; +import FormSwitch from '@client/components/form/switch.vue'; +import FormLink from '@client/components/form/link.vue'; +import FormBase from '@client/components/form/base.vue'; +import FormGroup from '@client/components/form/group.vue'; +import FormButton from '@client/components/form/button.vue'; +import FormKeyValueView from '@client/components/form/key-value-view.vue'; +import FormSuspense from '@client/components/form/suspense.vue'; +import * as os from '@client/os'; +import number from '@client/filters/number'; +import bytes from '@client/filters/bytes'; +import * as symbols from '@client/symbols'; +import { url } from '@client/config'; +import { userPage, acct } from '@client/filters/user'; + +export default defineComponent({ + components: { + FormBase, + FormSwitch, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + }, + + props: { + userId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.userInfo, + icon: 'fas fa-info-circle', + actions: this.user ? [this.user.url ? { + text: this.user.url, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(this.user.url, '_blank'); + } + } : undefined].filter(x => x !== undefined) : [], + })), + init: null, + user: null, + info: null, + moderator: false, + silenced: false, + suspended: false, + } + }, + + watch: { + userId: { + handler() { + this.init = this.createFetcher(); + }, + immediate: true + } + }, + + methods: { + number, + bytes, + userPage, + acct, + + createFetcher() { + return () => Promise.all([os.api('users/show', { + userId: this.userId + }), os.api('admin/show-user', { + userId: this.userId + })]).then(([user, info]) => { + this.user = user; + this.info = info; + this.moderator = this.info.isModerator; + this.silenced = this.info.isSilenced; + this.suspended = this.info.isSuspended; + }); + }, + + refreshUser() { + this.init = this.createFetcher(); + }, + + async updateRemoteUser() { + await os.apiWithDialog('admin/update-remote-user', { userId: this.user.id }); + this.refreshUser(); + }, + + async resetPassword() { + os.apiWithDialog('admin/reset-password', { + userId: this.user.id, + }, undefined, ({ password }) => { + os.dialog({ + type: 'success', + text: this.$t('newPasswordIs', { password }) + }); + }); + }, + + async toggleSilence(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm, + }); + if (confirm.canceled) { + this.silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleSuspend(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm, + }); + if (confirm.canceled) { + this.suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); + await this.refreshUser(); + }, + + async deleteAllFiles() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.deleteAllFilesConfirm, + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); + os.success(); + }; + await process().catch(e => { + os.dialog({ + type: 'error', + text: e.toString() + }); + }); + await this.refreshUser(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.aeakzknw { + > .avatar { + display: block; + margin: 0 auto; + width: 64px; + height: 64px; + } +} +</style> diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue index 4db965588..452886abd 100644 --- a/src/client/pages/instance/users.vue +++ b/src/client/pages/instance/users.vue @@ -1,86 +1,71 @@ <template> -<div class="mk-instance-users"> - <div class="_section"> - <div class="_content"> - <MkButton inline primary @click="addUser()"><i class="fas fa-plus"></i> {{ $ts.addUser }}</MkButton> - </div> +<div class="lknzcolw"> + <div class="actions"> + <MkButton inline primary @click="addUser()"><i class="fas fa-plus"></i> {{ $ts.addUser }}</MkButton> + <MkButton inline primary @click="lookupUser()"><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton> </div> - <div class="_section lookup"> - <div class="_title"><i class="fas fa-search"></i> {{ $ts.lookup }}</div> - <div class="_content"> - <MkInput class="target" v-model:value="target" type="text" @enter="showUser()"> - <span>{{ $ts.usernameOrUserId }}</span> + <div class="users"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.sort }}</template> + <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> + <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> + <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="available">{{ $ts.normal }}</option> + <option value="admin">{{ $ts.administrator }}</option> + <option value="moderator">{{ $ts.moderator }}</option> + <option value="silenced">{{ $ts.silence }}</option> + <option value="suspended">{{ $ts.suspend }}</option> + </MkSelect> + <MkSelect v-model:value="origin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.instance }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + </div> + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()"> + <span>{{ $ts.username }}</span> + </MkInput> + <MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> + <span>{{ $ts.host }}</span> </MkInput> - <MkButton @click="showUser()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton> </div> - </div> - <div class="_section users"> - <div class="_title"><i class="fas fa-users"></i> {{ $ts.users }}</div> - <div class="_content"> - <div class="inputs" style="display: flex;"> - <MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.sort }}</template> - <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> - <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> - <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> - <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> - </MkSelect> - <MkSelect v-model:value="state" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="available">{{ $ts.normal }}</option> - <option value="admin">{{ $ts.administrator }}</option> - <option value="moderator">{{ $ts.moderator }}</option> - <option value="silenced">{{ $ts.silence }}</option> - <option value="suspended">{{ $ts.suspend }}</option> - </MkSelect> - <MkSelect v-model:value="origin" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.instance }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> - </MkSelect> - </div> - <div class="inputs" style="display: flex; padding-top: 1.2em;"> - <MkInput v-model:value="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()"> - <span>{{ $ts.username }}</span> - </MkInput> - <MkInput v-model:value="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:value="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> - <span>{{ $ts.host }}</span> - </MkInput> - </div> - - <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users"> - <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)"> - <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> - <div class="body"> - <header> - <MkUserName class="name" :user="user"/> - <span class="acct">@{{ acct(user) }}</span> - <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span> - <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span> - <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span> - <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span> - </header> - <div> - <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> - </div> - <div> - <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span> - </div> + <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users"> + <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <header> + <MkUserName class="name" :user="user"/> + <span class="acct">@{{ acct(user) }}</span> + <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span> + <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span> + <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span> + <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span> + </header> + <div> + <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> </div> - </button> - </MkPagination> - </div> + <div> + <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span> + </div> + </div> + </button> + </MkPagination> </div> </div> </template> <script lang="ts"> import { defineComponent } from 'vue'; -import parseAcct from '@/misc/acct/parse'; import MkButton from '@client/components/ui/button.vue'; import MkInput from '@client/components/ui/input.vue'; import MkSelect from '@client/components/ui/select.vue'; @@ -88,6 +73,7 @@ import MkPagination from '@client/components/ui/pagination.vue'; import { acct } from '../../filters/user'; import * as os from '@client/os'; import * as symbols from '@client/symbols'; +import { lookupUser } from '@client/scripts/lookup-user'; export default defineComponent({ components: { @@ -97,6 +83,8 @@ export default defineComponent({ MkPagination, }, + emits: ['info'], + data() { return { [symbols.PAGE_INFO]: { @@ -107,7 +95,6 @@ export default defineComponent({ handler: this.searchUser } }, - target: '', sort: '+createdAt', state: 'all', origin: 'local', @@ -140,40 +127,12 @@ export default defineComponent({ }, }, - methods: { - /** テキストエリアのユーザーを解決する */ - fetchUser() { - return new Promise((res) => { - const usernamePromise = os.api('users/show', parseAcct(this.target)); - const idPromise = os.api('users/show', { userId: this.target }); - let _notFound = false; - const notFound = () => { - if (_notFound) { - os.dialog({ - type: 'error', - text: this.$ts.noSuchUser - }); - } else { - _notFound = true; - } - }; - usernamePromise.then(res).catch(e => { - if (e.code === 'NO_SUCH_USER') { - notFound(); - } - }); - idPromise.then(res).catch(e => { - notFound(); - }); - }); - }, + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, - /** テキストエリアから処理対象ユーザーを設定する */ - async showUser() { - const user = await this.fetchUser(); - this.show(user); - this.target = ''; - }, + methods: { + lookupUser, searchUser() { os.selectUser().then(user => { @@ -203,9 +162,7 @@ export default defineComponent({ }, show(user) { - os.popup(import('./user-dialog.vue'), { - userId: user.id - }, {}, 'closed'); + os.pageWindow(`/instance/user/${user.id}`); }, acct @@ -214,57 +171,61 @@ export default defineComponent({ </script> <style lang="scss" scoped> -.mk-instance-users { +.lknzcolw { + > .actions { + margin: var(--margin); + } + > .users { - > ._content { - > .users { - margin-top: var(--margin); + margin: var(--margin); + + > .users { + margin-top: var(--margin); - > .user { - display: flex; - width: 100%; - box-sizing: border-box; - text-align: left; - align-items: center; - padding: 16px; + > .user { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + padding: 16px; - &:hover { - color: var(--accent); + &:hover { + color: var(--accent); + } + + > .avatar { + width: 60px; + height: 60px; + } + + > .body { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; } - > .avatar { - width: 60px; - height: 60px; - } - - > .body { - margin-left: 0.3em; - padding: 0 8px; - flex: 1; - - @media (max-width: 500px) { - font-size: 14px; + > header { + > .name { + font-weight: bold; } - > header { - > .name { - font-weight: bold; - } + > .acct { + margin-left: 8px; + opacity: 0.7; + } - > .acct { - margin-left: 8px; - opacity: 0.7; - } + > .staff { + margin-left: 0.5em; + color: var(--badge); + } - > .staff { - margin-left: 0.5em; - color: var(--badge); - } - - > .punished { - margin-left: 0.5em; - color: #4dabf7; - } + > .punished { + margin-left: 0.5em; + color: #4dabf7; } } } diff --git a/src/client/pages/user-info.vue b/src/client/pages/user-info.vue index ebe462f79..378fbb7b5 100644 --- a/src/client/pages/user-info.vue +++ b/src/client/pages/user-info.vue @@ -1,34 +1,36 @@ <template> <FormBase> - <FormGroup v-if="user"> - <template #label><MkAcct :user="user"/></template> - - <FormKeyValueView> - <template #key>ID</template> - <template #value><span class="_monospace">{{ user.id }}</span></template> - </FormKeyValueView> - + <FormSuspense :p="init"> <FormGroup> - <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> + <template #label><MkAcct :user="user"/></template> - <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> - <FormKeyValueView v-else> - <template #key>{{ $ts.instanceInfo }}</template> - <template #value>(Local user)</template> - </FormKeyValueView> - </FormGroup> - - <FormGroup> <FormKeyValueView> - <template #key>{{ $ts.updatedAt }}</template> - <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> </FormKeyValueView> - </FormGroup> - <FormObjectView tall :value="user"> - <span>Raw</span> - </FormObjectView> - </FormGroup> + <FormGroup> + <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> + + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + + <FormObjectView tall :value="user"> + <span>Raw</span> + </FormObjectView> + </FormGroup> + </FormSuspense> </FormBase> </template> @@ -80,23 +82,27 @@ export default defineComponent({ } } : undefined].filter(x => x !== undefined) : [], })), + init: null, user: null, } }, - mounted() { - this.fetch(); + watch: { + userId: { + handler() { + this.init = () => os.api('users/show', { + userId: this.userId + }).then(user => { + this.user = user; + }); + }, + immediate: true + } }, methods: { number, bytes, - - async fetch() { - this.user = await os.api('users/show', { - userId: this.userId - }); - } } }); </script> diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index 477f23506..207b44f63 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -195,7 +195,7 @@ <template v-if="page === 'index'"> <div> - <div v-if="user.pinnedNotes.length > 0"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> <XNote v-for="note in user.pinnedNotes" class="note _block" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/> </div> <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo> diff --git a/src/client/router.ts b/src/client/router.ts index bf45c806e..93de287ea 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -59,17 +59,9 @@ export const router = createRouter({ { path: '/my/antennas', component: page('my-antennas/index') }, { path: '/my/clips', component: page('my-clips/index') }, { path: '/scratchpad', component: page('scratchpad') }, + { path: '/instance/user/:user', component: page('instance/user'), props: route => ({ userId: route.params.user }) }, + { path: '/instance/:page(.*)?', component: page('instance/index'), props: route => ({ initialPage: route.params.page || null }) }, { path: '/instance', component: page('instance/index') }, - { path: '/instance/emojis', component: page('instance/emojis') }, - { path: '/instance/users', component: page('instance/users') }, - { path: '/instance/logs', component: page('instance/logs') }, - { path: '/instance/files', component: page('instance/files') }, - { path: '/instance/queue', component: page('instance/queue') }, - { path: '/instance/settings', component: page('instance/settings') }, - { path: '/instance/federation', component: page('instance/federation') }, - { path: '/instance/relays', component: page('instance/relays') }, - { path: '/instance/announcements', component: page('instance/announcements') }, - { path: '/instance/abuses', component: page('instance/abuses') }, { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) }, diff --git a/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts index ceb2bfe17..9a003b5c3 100644 --- a/src/client/scripts/get-user-menu.ts +++ b/src/client/scripts/get-user-menu.ts @@ -124,7 +124,13 @@ export function getUserMenu(user) { action: () => { copyToClipboard(`@${user.username}@${user.host || host}`); } - }, { + }, ($i && ($i.isAdmin || $i.isModerator)) ? { + icon: 'fas fa-info-circle', + text: i18n.locale.info, + action: () => { + os.pageWindow(`/instance/user/${user.id}`); + } + } : { icon: 'fas fa-info-circle', text: i18n.locale.info, action: () => { diff --git a/src/client/scripts/lookup-user.ts b/src/client/scripts/lookup-user.ts new file mode 100644 index 000000000..1bcfd8e9d --- /dev/null +++ b/src/client/scripts/lookup-user.ts @@ -0,0 +1,37 @@ +import parseAcct from '@/misc/acct/parse'; +import { i18n } from '@client/i18n'; +import * as os from '@client/os'; + +export async function lookupUser() { + const { canceled, result } = await os.dialog({ + title: i18n.locale.usernameOrUserId, + input: true + }); + if (canceled) return; + + const show = (user) => { + os.pageWindow(`/instance/user/${user.id}`); + }; + + const usernamePromise = os.api('users/show', parseAcct(result)); + const idPromise = os.api('users/show', { userId: result }); + let _notFound = false; + const notFound = () => { + if (_notFound) { + os.dialog({ + type: 'error', + text: i18n.locale.noSuchUser + }); + } else { + _notFound = true; + } + }; + usernamePromise.then(show).catch(e => { + if (e.code === 'NO_SUCH_USER') { + notFound(); + } + }); + idPromise.then(show).catch(e => { + notFound(); + }); +} diff --git a/src/client/ui/_common_/sidebar.vue b/src/client/ui/_common_/sidebar.vue index fefe6afa7..45b1c079b 100644 --- a/src/client/ui/_common_/sidebar.vue +++ b/src/client/ui/_common_/sidebar.vue @@ -25,9 +25,9 @@ </component> </template> <div class="divider"></div> - <button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$i.isAdmin || $i.isModerator" @click="oepnInstanceMenu"> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/instance"> <i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span> - </button> + </MkA> <button class="item _button" @click="more"> <i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> @@ -172,65 +172,6 @@ export default defineComponent({ }); }, - oepnInstanceMenu(ev) { - os.modalMenu([{ - type: 'link', - text: this.$ts.dashboard, - to: '/instance', - icon: 'fas fa-tachometer-alt', - }, null, this.$i.isAdmin ? { - type: 'link', - text: this.$ts.settings, - to: '/instance/settings', - icon: 'fas fa-cog', - } : undefined, { - type: 'link', - text: this.$ts.customEmojis, - to: '/instance/emojis', - icon: 'fas fa-laugh', - }, { - type: 'link', - text: this.$ts.users, - to: '/instance/users', - icon: 'fas fa-users', - }, { - type: 'link', - text: this.$ts.files, - to: '/instance/files', - icon: 'fas fa-cloud', - }, { - type: 'link', - text: this.$ts.jobQueue, - to: '/instance/queue', - icon: 'fas fa-exchange-alt', - }, { - type: 'link', - text: this.$ts.federation, - to: '/instance/federation', - icon: 'fas fa-globe', - }, { - type: 'link', - text: this.$ts.relays, - to: '/instance/relays', - icon: 'fas fa-project-diagram', - }, { - type: 'link', - text: this.$ts.announcements, - to: '/instance/announcements', - icon: 'fas fa-broadcast-tower', - }, { - type: 'link', - text: this.$ts.abuseReports, - to: '/instance/abuses', - icon: 'fas fa-exclamation-circle', - }, { - type: 'link', - text: this.$ts.logs, - to: '/instance/logs', - icon: 'fas fa-stream', - }], ev.currentTarget || ev.target); - }, - more(ev) { os.popup(import('@client/components/launch-pad.vue'), {}, { }, 'closed'); diff --git a/src/client/ui/default.sidebar.vue b/src/client/ui/default.sidebar.vue index 952ec3903..29ef99fc8 100644 --- a/src/client/ui/default.sidebar.vue +++ b/src/client/ui/default.sidebar.vue @@ -20,9 +20,9 @@ </component> </template> <div class="divider"></div> - <button class="item _button" :class="{ active: $route.path === '/instance' || $route.path.startsWith('/instance/') }" v-if="$i.isAdmin || $i.isModerator" @click="oepnInstanceMenu"> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/instance" :behavior="settingsWindowed ? 'modalWindow' : null"> <i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span> - </button> + </MkA> <button class="item _button" @click="more"> <i class="fas fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> @@ -156,65 +156,6 @@ export default defineComponent({ }); }, - oepnInstanceMenu(ev) { - os.modalMenu([{ - type: 'link', - text: this.$ts.dashboard, - to: '/instance', - icon: 'fas fa-tachometer-alt', - }, null, this.$i.isAdmin ? { - type: 'link', - text: this.$ts.settings, - to: '/instance/settings', - icon: 'fas fa-cog', - } : undefined, { - type: 'link', - text: this.$ts.customEmojis, - to: '/instance/emojis', - icon: 'fas fa-laugh', - }, { - type: 'link', - text: this.$ts.users, - to: '/instance/users', - icon: 'fas fa-users', - }, { - type: 'link', - text: this.$ts.files, - to: '/instance/files', - icon: 'fas fa-cloud', - }, { - type: 'link', - text: this.$ts.jobQueue, - to: '/instance/queue', - icon: 'fas fa-exchange-alt', - }, { - type: 'link', - text: this.$ts.federation, - to: '/instance/federation', - icon: 'fas fa-globe', - }, { - type: 'link', - text: this.$ts.relays, - to: '/instance/relays', - icon: 'fas fa-project-diagram', - }, { - type: 'link', - text: this.$ts.announcements, - to: '/instance/announcements', - icon: 'fas fa-broadcast-tower', - }, { - type: 'link', - text: this.$ts.abuseReports, - to: '/instance/abuses', - icon: 'fas fa-exclamation-circle', - }, { - type: 'link', - text: this.$ts.logs, - to: '/instance/logs', - icon: 'fas fa-stream', - }], ev.currentTarget || ev.target); - }, - more(ev) { os.popup(import('@client/components/launch-pad.vue'), {}, { }, 'closed');