0
0

インスタンス管理画面作り直し (#7473)

* wip

* wip

* wip

* wip
This commit is contained in:
syuilo 2021-04-22 22:29:33 +09:00 committed by GitHub
parent ec75600e1c
commit 246693b848
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 2588 additions and 1887 deletions

View File

@ -183,7 +183,7 @@ clearQueueConfirmTitle: "キューをクリアしますか?"
clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。" clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。"
clearCachedFiles: "キャッシュをクリア" clearCachedFiles: "キャッシュをクリア"
clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?" clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?"
blockedInstances: "インスタンスブロック" blockedInstances: "ブロックしたインスタンス"
blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。" blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。"
muteAndBlock: "ミュートとブロック" muteAndBlock: "ミュートとブロック"
mutedUsers: "ミュートしたユーザー" mutedUsers: "ミュートしたユーザー"
@ -349,7 +349,6 @@ antennaExcludeKeywords: "除外キーワード"
antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります"
notifyAntenna: "新しいノートを通知する" notifyAntenna: "新しいノートを通知する"
withFileAntenna: "ファイルが添付されたノートのみ" withFileAntenna: "ファイルが添付されたノートのみ"
serviceworker: "ServiceWorker"
enableServiceworker: "ServiceWorkerを有効にする" enableServiceworker: "ServiceWorkerを有効にする"
antennaUsersDescription: "ユーザー名を改行で区切って指定します" antennaUsersDescription: "ユーザー名を改行で区切って指定します"
caseSensitive: "大文字小文字を区別する" caseSensitive: "大文字小文字を区別する"
@ -568,7 +567,7 @@ pluginTokenRequestedDescription: "このプラグインはここで設定した
notificationType: "通知の種類" notificationType: "通知の種類"
edit: "編集" edit: "編集"
useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う" useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う"
emailConfig: "メールサーバー設定" emailServer: "メールサーバー"
enableEmail: "メール配信機能を有効化する" enableEmail: "メール配信機能を有効化する"
emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います"
email: "メール" email: "メール"
@ -728,6 +727,14 @@ hideOnlineStatusDescription: "オンライン状態を隠すと、検索など
online: "オンライン" online: "オンライン"
active: "アクティブ" active: "アクティブ"
offline: "オフライン" offline: "オフライン"
notRecommended: "非推奨"
botProtection: "Bot防御"
instanceBlocking: "インスタンスブロック"
selectAccount: "アカウントを選択"
enabled: "有効"
disabled: "無効"
quickAction: "クイックアクション"
user: "ユーザー"
_email: _email:
_follow: _follow:

View File

@ -1,7 +1,7 @@
{ {
"name": "misskey", "name": "misskey",
"author": "syuilo <syuilotan@yahoo.co.jp>", "author": "syuilo <syuilotan@yahoo.co.jp>",
"version": "12.78.0-beta.2", "version": "12.78.0-beta.3",
"codename": "indigo", "codename": "indigo",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -18,7 +18,7 @@ type Captcha = {
getResponse(id: string): string; getResponse(id: string): string;
}; };
type CaptchaProvider = 'hcaptcha' | 'grecaptcha'; type CaptchaProvider = 'hcaptcha' | 'recaptcha';
type CaptchaContainer = { type CaptchaContainer = {
readonly [_ in CaptchaProvider]?: Captcha; readonly [_ in CaptchaProvider]?: Captcha;
@ -57,7 +57,7 @@ export default defineComponent({
src() { src() {
const endpoint = ({ const endpoint = ({
hcaptcha: 'https://hcaptcha.com/1', hcaptcha: 'https://hcaptcha.com/1',
grecaptcha: 'https://www.recaptcha.net/recaptcha', recaptcha: 'https://www.recaptcha.net/recaptcha',
} as Record<PropertyKey, unknown>)[this.provider]; } as Record<PropertyKey, unknown>)[this.provider];
return `${typeof endpoint == 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; return `${typeof endpoint == 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;

View File

@ -24,6 +24,8 @@ export default defineComponent({
--formXPadding: 32px; --formXPadding: 32px;
--formYPadding: 32px; --formYPadding: 32px;
--formContentHMargin: 16px;
font-size: 95%; font-size: 95%;
line-height: 1.3em; line-height: 1.3em;
background: var(--bg); background: var(--bg);

View File

@ -30,7 +30,7 @@
top: var(--stickyTop, 0px); top: var(--stickyTop, 0px);
z-index: 2; z-index: 2;
margin: -8px calc(var(--formXPadding) * -1) 0 calc(var(--formXPadding) * -1); 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); background: var(--X17);
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@ -42,7 +42,7 @@
} }
._formCaption { ._formCaption {
padding: 8px 16px 0 16px; padding: 8px var(--formContentHMargin) 0 var(--formContentHMargin);
} }
._formItem { ._formItem {

View File

@ -20,7 +20,7 @@ export default defineComponent({
.anocepby { .anocepby {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 14px 16px; padding: 14px var(--formContentHMargin);
> .key { > .key {
margin-right: 12px; margin-right: 12px;

View File

@ -75,7 +75,7 @@ export default defineComponent({
max-width: 100%; max-width: 100%;
min-height: 130px; min-height: 130px;
margin: 0; margin: 0;
padding: 16px; padding: 16px var(--formContentHMargin);
box-sizing: border-box; box-sizing: border-box;
font: inherit; font: inherit;
font-weight: normal; font-weight: normal;

View File

@ -18,6 +18,9 @@ export default defineComponent({
} }
}, },
watch: { watch: {
modelValue() {
this.value = this.modelValue;
},
value() { value() {
this.$emit('update:modelValue', this.value); this.$emit('update:modelValue', this.value);
} }

View File

@ -5,9 +5,9 @@
<MkLoading/> <MkLoading/>
</div> </div>
</div> </div>
<FormGroup v-else-if="resolved" class="_formItem"> <div v-else-if="resolved" class="_formItem">
<slot :result="result"></slot> <slot :result="result"></slot>
</FormGroup> </div>
<div class="_formItem" v-else> <div class="_formItem" v-else>
<div class="_formPanel"> <div class="_formPanel">
error! error!
@ -20,13 +20,8 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, PropType, ref, watch } from 'vue'; import { defineComponent, PropType, ref, watch } from 'vue';
import './form.scss'; import './form.scss';
import FormGroup from './group.vue';
export default defineComponent({ export default defineComponent({
components: {
FormGroup,
},
props: { props: {
p: { p: {
type: Function as PropType<() => Promise<any>>, type: Function as PropType<() => Promise<any>>,

View File

@ -1,123 +1,35 @@
<template> <template>
<div class="zbcjwnqg" v-size="{ max: [550, 1000] }"> <div class="zbcjwnqg" style="margin-top: -8px;">
<div class="stats" v-if="info"> <div class="selects" style="display: flex;">
<div class="_panel"> <MkSelect v-model:value="chartSrc" style="margin: 0; flex: 1;">
<div> <optgroup :label="$ts.federation">
<b><i class="fas fa-user"></i>{{ $ts.users }}</b> <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option>
<small>{{ $ts.local }}</small> <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option>
</div> </optgroup>
<div> <optgroup :label="$ts.users">
<dl class="total"> <option value="users">{{ $ts._charts.usersIncDec }}</option>
<dt>{{ $ts.total }}</dt> <option value="users-total">{{ $ts._charts.usersTotal }}</option>
<dd>{{ number(info.originalUsersCount) }}</dd> <option value="active-users">{{ $ts._charts.activeUsers }}</option>
</dl> </optgroup>
<dl class="diff" :class="{ inc: usersLocalDoD > 0 }"> <optgroup :label="$ts.notes">
<dt>{{ $ts.dayOverDayChanges }}</dt> <option value="notes">{{ $ts._charts.notesIncDec }}</option>
<dd>{{ number(usersLocalDoD) }}</dd> <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option>
</dl> <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option>
<dl class="diff" :class="{ inc: usersLocalWoW > 0 }"> <option value="notes-total">{{ $ts._charts.notesTotal }}</option>
<dt>{{ $ts.weekOverWeekChanges }}</dt> </optgroup>
<dd>{{ number(usersLocalWoW) }}</dd> <optgroup :label="$ts.drive">
</dl> <option value="drive-files">{{ $ts._charts.filesIncDec }}</option>
</div> <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option>
</div> <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option>
<div class="_panel"> <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
<div> </optgroup>
<b><i class="fas fa-user"></i>{{ $ts.users }}</b> </MkSelect>
<small>{{ $ts.remote }}</small> <MkSelect v-model:value="chartSpan" style="margin: 0;">
</div> <option value="hour">{{ $ts.perHour }}</option>
<div> <option value="day">{{ $ts.perDay }}</option>
<dl class="total"> </MkSelect>
<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> </div>
<canvas ref="chart"></canvas>
<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>
</div> </div>
</template> </template>
@ -158,7 +70,6 @@ export default defineComponent({
data() { data() {
return { return {
info: null,
notesLocalWoW: 0, notesLocalWoW: 0,
notesLocalDoD: 0, notesLocalDoD: 0,
notesRemoteWoW: 0, notesRemoteWoW: 0,
@ -216,8 +127,6 @@ export default defineComponent({
}, },
async created() { async created() {
this.info = await os.api('stats');
this.now = new Date(); this.now = new Date();
this.fetchChart(); 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.chart = chart;
this.renderChart(); this.renderChart();
@ -300,10 +200,10 @@ export default defineComponent({
aspectRatio: 2.5, aspectRatio: 2.5,
layout: { layout: {
padding: { padding: {
left: 0, left: 16,
right: 0, right: 16,
top: 16, top: 16,
bottom: 0 bottom: 8
} }
}, },
legend: { legend: {
@ -630,90 +530,8 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.zbcjwnqg { .zbcjwnqg {
&.max-width_1000px { > .selects {
> .stats { padding: 8px 16px 0 16px;
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: "+";
}
}
}
}
}
}
}
} }
} }
</style> </style>

View File

@ -45,7 +45,7 @@
</I18n> </I18n>
</label> </label>
<captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model:value="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> <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> <MkButton type="submit" :disabled="shouldDisableSubmitting" primary>{{ $ts.start }}</MkButton>
</template> </template>
</form> </form>

View File

@ -29,6 +29,7 @@ export default defineComponent({
<style lang="scss"> <style lang="scss">
.pxhvhrfw { .pxhvhrfw {
display: flex; display: flex;
font-size: 90%;
> button { > button {
flex: 1; flex: 1;

View File

@ -1,16 +1,23 @@
<template> <template>
<div class="cxiknjgy"> <transition name="fade" mode="out-in">
<slot :items="items"></slot> <MkLoading v-if="fetching"/>
<div class="empty" v-if="empty" key="_empty_">
<MkError v-else-if="error" @retry="init()"/>
<div class="empty" v-else-if="empty" key="_empty_">
<slot name="empty"></slot> <slot name="empty"></slot>
</div> </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> <div v-else class="cxiknjgy">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template> <slot :items="items"></slot>
<template v-if="moreFetching"><MkLoading inline/></template> <div class="more" v-show="more" key="_more_">
</MkButton> <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>
</div> </transition>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -36,6 +43,15 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.cxiknjgy { .cxiknjgy {
> .more > .button { > .more > .button {
margin-left: auto; margin-left: auto;

View File

@ -1,5 +1,5 @@
<template> <template>
<div class=""> <div class="lcixvhis">
<div class="_section reports"> <div class="_section reports">
<div class="_content"> <div class="_content">
<div class="inputs" style="display: flex;"> <div class="inputs" style="display: flex;">
@ -80,6 +80,8 @@ export default defineComponent({
MkPagination, MkPagination,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
@ -117,6 +119,10 @@ export default defineComponent({
}, },
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
acct, acct,
@ -132,6 +138,10 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.lcixvhis {
margin: var(--margin);
}
.bcekxzvu { .bcekxzvu {
> .target { > .target {
display: flex; display: flex;

View File

@ -1,28 +1,24 @@
<template> <template>
<div class="ztgjmzrw"> <div class="ztgjmzrw">
<div class="_section"> <MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
<div class="_content"> <section class="_card _gap announcements" v-for="announcement in announcements">
<MkButton @click="add()" primary style="margin: 0 auto 16px auto;"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> <div class="_content announcement">
<section class="_card _gap announcements" v-for="announcement in announcements"> <MkInput v-model:value="announcement.title">
<div class="_content announcement"> <span>{{ $ts.title }}</span>
<MkInput v-model:value="announcement.title"> </MkInput>
<span>{{ $ts.title }}</span> <MkTextarea v-model:value="announcement.text">
</MkInput> <span>{{ $ts.text }}</span>
<MkTextarea v-model:value="announcement.text"> </MkTextarea>
<span>{{ $ts.text }}</span> <MkInput v-model:value="announcement.imageUrl">
</MkTextarea> <span>{{ $ts.imageUrl }}</span>
<MkInput v-model:value="announcement.imageUrl"> </MkInput>
<span>{{ $ts.imageUrl }}</span> <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
</MkInput> <div class="buttons">
<p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
<div class="buttons"> <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
<MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> </div>
<MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
</div>
</div>
</section>
</div> </div>
</div> </section>
</div> </div>
</template> </template>
@ -41,6 +37,8 @@ export default defineComponent({
MkTextarea, MkTextarea,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
@ -57,6 +55,10 @@ export default defineComponent({
}); });
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
add() { add() {
this.announcements.unshift({ this.announcements.unshift({
@ -109,3 +111,9 @@ export default defineComponent({
} }
}); });
</script> </script>
<style lang="scss" scoped>
.ztgjmzrw {
margin: var(--margin);
}
</style>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,50 +1,46 @@
<template> <template>
<div class="mk-instance-emojis"> <div class="ogwlenmc">
<div class="_section" style="padding: 0;"> <MkTab v-model:value="tab">
<MkTab v-model:value="tab"> <option value="local">{{ $ts.local }}</option>
<option value="local">{{ $ts.local }}</option> <option value="remote">{{ $ts.remote }}</option>
<option value="remote">{{ $ts.remote }}</option> </MkTab>
</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>
<div class="_section"> <div class="remote" v-else-if="tab === 'remote'">
<div class="local" v-if="tab === 'local'"> <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>
<MkButton primary @click="add" style="margin: 0 auto var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.addEmoji }}</MkButton> <MkInput v-model:value="host" :debounce="true" style="margin: var(--margin);"><span>{{ $ts.host }}</span></MkInput>
<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="remotePagination" ref="remoteEmojis">
<MkPagination :pagination="pagination" ref="emojis"> <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
<template #empty><span>{{ $ts.noCustomEmojis }}</span></template> <template #default="{items}">
<template #default="{items}"> <div class="ldhfsamy">
<div class="emojis"> <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)">
<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"/>
<img :src="emoji.url" class="img" :alt="emoji.name"/> <div class="body">
<div class="body"> <div class="name _monospace">{{ emoji.name }}</div>
<div class="name">{{ emoji.name }}</div> <div class="info">{{ emoji.host }}</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> </div>
</div> </div>
</template> </div>
</MkPagination> </template>
</div> </MkPagination>
</div> </div>
</div> </div>
</template> </template>
@ -67,6 +63,8 @@ export default defineComponent({
MkPagination, MkPagination,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
@ -99,6 +97,10 @@ export default defineComponent({
} }
}, },
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
async add(e) { async add(e) {
const files = await selectFile(e.currentTarget || e.target, null, true); const files = await selectFile(e.currentTarget || e.target, null, true);
@ -150,85 +152,86 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-instance-emojis { .ogwlenmc {
> ._section { > .local {
> .local { .ldhfsamy {
.emojis { display: grid;
display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); grid-gap: 12px;
grid-gap: var(--margin); margin: var(--margin);
> .emoji { > .emoji {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px; padding: 12px;
text-align: left; text-align: left;
&:hover { &:hover {
color: var(--accent); color: var(--accent);
} }
> .img { > .img {
width: 42px; width: 42px;
height: 42px; height: 42px;
} }
> .body { > .body {
padding: 0 0 0 8px; padding: 0 0 0 8px;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
> .name {
text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
}
> .name { > .info {
text-overflow: ellipsis; opacity: 0.5;
overflow: hidden; text-overflow: ellipsis;
} overflow: hidden;
> .info {
opacity: 0.5;
text-overflow: ellipsis;
overflow: hidden;
}
} }
} }
} }
} }
}
> .remote { > .remote {
.emojis { .ldhfsamy {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: var(--margin); grid-gap: 12px;
margin: var(--margin);
> .emoji { > .emoji {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px; padding: 12px;
text-align: left; text-align: left;
&:hover { &:hover {
color: var(--accent); color: var(--accent);
} }
> .img { > .img {
width: 32px; width: 32px;
height: 32px; height: 32px;
} }
> .body { > .body {
padding: 0 0 0 8px; padding: 0 0 0 8px;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
> .name {
text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
}
> .name { > .info {
text-overflow: ellipsis; opacity: 0.5;
overflow: hidden; font-size: 90%;
} text-overflow: ellipsis;
overflow: hidden;
> .info {
opacity: 0.5;
text-overflow: ellipsis;
overflow: hidden;
}
} }
} }
} }

View File

@ -1,60 +1,55 @@
<template> <template>
<div> <div class="enuoauvw">
<div class="_section"> <div class="query">
<div class="_content"> <MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput>
<MkInput v-model:value="host" :debounce="true"><span>{{ $ts.host }}</span></MkInput> <div class="inputs" style="display: flex;">
<div class="inputs" style="display: flex;"> <MkSelect v-model:value="state" style="margin: 0; flex: 1;">
<MkSelect v-model:value="state" style="margin: 0; flex: 1;"> <template #label>{{ $ts.state }}</template>
<template #label>{{ $ts.state }}</template> <option value="all">{{ $ts.all }}</option>
<option value="all">{{ $ts.all }}</option> <option value="federating">{{ $ts.federating }}</option>
<option value="federating">{{ $ts.federating }}</option> <option value="subscribing">{{ $ts.subscribing }}</option>
<option value="subscribing">{{ $ts.subscribing }}</option> <option value="publishing">{{ $ts.publishing }}</option>
<option value="publishing">{{ $ts.publishing }}</option> <option value="suspended">{{ $ts.suspended }}</option>
<option value="suspended">{{ $ts.suspended }}</option> <option value="blocked">{{ $ts.blocked }}</option>
<option value="blocked">{{ $ts.blocked }}</option> <option value="notResponding">{{ $ts.notResponding }}</option>
<option value="notResponding">{{ $ts.notResponding }}</option> </MkSelect>
</MkSelect> <MkSelect v-model:value="sort" style="margin: 0; flex: 1;">
<MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> <template #label>{{ $ts.sort }}</template>
<template #label>{{ $ts.sort }}</template> <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
<option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
<option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
<option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
<option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
<option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
<option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
<option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
<option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
<option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
<option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> <option value="+caughtAt">{{ $ts.caughtAt }} ({{ $ts.descendingOrder }})</option>
<option value="+caughtAt">{{ $ts.caughtAt }} ({{ $ts.descendingOrder }})</option> <option value="-caughtAt">{{ $ts.caughtAt }} ({{ $ts.ascendingOrder }})</option>
<option value="-caughtAt">{{ $ts.caughtAt }} ({{ $ts.ascendingOrder }})</option> <option value="+lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.descendingOrder }})</option>
<option value="+lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.descendingOrder }})</option> <option value="-lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.ascendingOrder }})</option>
<option value="-lastCommunicatedAt">{{ $ts.lastCommunicatedAt }} ({{ $ts.ascendingOrder }})</option> <option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option>
<option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option> <option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option>
<option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option> <option value="+driveFiles">{{ $ts.driveFiles }} ({{ $ts.descendingOrder }})</option>
<option value="+driveFiles">{{ $ts.driveFiles }} ({{ $ts.descendingOrder }})</option> <option value="-driveFiles">{{ $ts.driveFiles }} ({{ $ts.ascendingOrder }})</option>
<option value="-driveFiles">{{ $ts.driveFiles }} ({{ $ts.ascendingOrder }})</option> </MkSelect>
</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>
</div> </MkPagination>
<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>
</div> </div>
</template> </template>
@ -76,6 +71,8 @@ export default defineComponent({
MkPagination, MkPagination,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
@ -114,6 +111,10 @@ export default defineComponent({
} }
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
getStatus(instance) { getStatus(instance) {
if (instance.isSuspended) return 'off'; if (instance.isSuspended) return 'off';
@ -131,6 +132,12 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.enuoauvw {
> .query {
margin: var(--margin);
}
}
.ppgwaixt { .ppgwaixt {
cursor: pointer; cursor: pointer;
padding: 16px; padding: 16px;

View File

@ -82,9 +82,7 @@ export default defineComponent({
}, },
showUser() { showUser() {
os.popup(import('./user-dialog.vue'), { os.pageWindow(`/instance/user/${this.file.userId}`);
userId: this.file.userId
}, {}, 'closed');
}, },
async del() { async del() {

View File

@ -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>

View File

@ -80,6 +80,8 @@ export default defineComponent({
MkDriveFileThumbnail, MkDriveFileThumbnail,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
@ -114,6 +116,10 @@ export default defineComponent({
}, },
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
clear() { clear() {
os.dialog({ os.dialog({
@ -153,6 +159,8 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.xrmjdkdw { .xrmjdkdw {
margin: var(--margin);
.urempief { .urempief {
margin-top: var(--margin); margin-top: var(--margin);

View File

@ -1,171 +1,239 @@
<template> <template>
<div v-if="meta" v-show="page === 'index'" class="xhexznfu _section"> <div class="hiyeyicy" :class="{ wide: !narrow }" ref="el">
<MkFolder> <div class="nav" v-if="!narrow || page == null">
<template #header><i class="fas fa-tachometer-alt"></i> {{ $ts.overview }}</template> <FormBase>
<FormGroup>
<div class="sboqnrfi" :style="{ gridTemplateRows: overviewHeight }"> <div class="_formItem">
<MkInstanceStats :chart-limit="300" :detailed="true" class="_gap" ref="stats"/> <div class="_formPanel lxpfedzu">
<img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
<MkContainer :foldable="true" class="_gap"> </div>
<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> </div>
<div class="_content" v-if="serverInfo"> <FormLink :active="page === 'overview'" replace to="/instance/overview"><template #icon><i class="fas fa-tachometer-alt"></i></template>{{ $ts.overview }}</FormLink>
<div class="_keyValue"><b>Node.js</b><span>{{ serverInfo.node }}</span></div> </FormGroup>
<div class="_keyValue"><b>PostgreSQL</b><span>v{{ serverInfo.psql }}</span></div> <FormGroup>
<div class="_keyValue"><b>Redis</b><span>v{{ serverInfo.redis }}</span></div> <template #label>{{ $ts.quickAction }}</template>
</div> <FormButton @click="lookup"><i class="fas fa-search"></i> {{ $ts.lookup }}</FormButton>
</MkContainer> <FormButton v-if="$instance.disableRegistration" @click="invite"><i class="fas fa-user"></i> {{ $ts.invite }}</FormButton>
</FormGroup>
<MkContainer :foldable="true" :scrollable="true" class="_gap" style="height: 300px;"> <FormGroup>
<template #header><i class="fas fa-database"></i>{{ $ts.database }}</template> <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>
<div class="_content" v-if="dbInfo"> <FormLink :active="page === 'federation'" replace to="/instance/federation"><template #icon><i class="fas fa-globe"></i></template>{{ $ts.federation }}</FormLink>
<table style="border-collapse: collapse; width: 100%;"> <FormLink :active="page === 'queue'" replace to="/instance/queue"><template #icon><i class="fas fa-clipboard-list"></i></template>{{ $ts.jobQueue }}</FormLink>
<tr style="opacity: 0.7;"> <FormLink :active="page === 'files'" replace to="/instance/files"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink>
<th style="text-align: left; padding: 0 8px 8px 0;">Table</th> <FormLink :active="page === 'announcements'" replace to="/instance/announcements"><template #icon><i class="fas fa-broadcast-tower"></i></template>{{ $ts.announcements }}</FormLink>
<th style="text-align: left; padding: 0 8px 8px 0;">Records</th> <FormLink :active="page === 'database'" replace to="/instance/database"><template #icon><i class="fas fa-database"></i></template>{{ $ts.database }}</FormLink>
<th style="text-align: left; padding: 0 0 8px 0;">Size</th> <FormLink :active="page === 'abuses'" replace to="/instance/abuses"><template #icon><i class="fas fa-exclamation-circle"></i></template>{{ $ts.abuseReports }}</FormLink>
</tr> </FormGroup>
<tr v-for="table in dbInfo" :key="table[0]"> <FormGroup>
<th style="text-align: left; padding: 0 8px 0 0; word-break: break-all;">{{ table[0] }}</th> <template #label>{{ $ts.settings }}</template>
<td style="padding: 0 8px 0 0;">{{ number(table[1].count) }}</td> <FormLink :active="page === 'settings'" replace to="/instance/settings"><template #icon><i class="fas fa-cog"></i></template>{{ $ts.general }}</FormLink>
<td style="padding: 0; opacity: 0.7;">{{ bytes(table[1].size) }}</td> <FormLink :active="page === 'files-settings'" replace to="/instance/files-settings"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.files }}</FormLink>
</tr> <FormLink :active="page === 'email-settings'" replace to="/instance/email-settings"><template #icon><i class="fas fa-envelope"></i></template>{{ $ts.emailServer }}</FormLink>
</table> <FormLink :active="page === 'object-storage'" replace to="/instance/object-storage"><template #icon><i class="fas fa-cloud"></i></template>{{ $ts.objectStorage }}</FormLink>
</div> <FormLink :active="page === 'security'" replace to="/instance/security"><template #icon><i class="fas fa-lock"></i></template>{{ $ts.security }}</FormLink>
</MkContainer> <FormLink :active="page === 'service-worker'" replace to="/instance/service-worker"><template #icon><i class="fas fa-bolt"></i></template>ServiceWorker</FormLink>
</div> <FormLink :active="page === 'relays'" replace to="/instance/relays"><template #icon><i class="fas fa-globe"></i></template>{{ $ts.relays }}</FormLink>
</MkFolder> <FormLink :active="page === 'integrations'" replace to="/instance/integrations"><template #icon><i class="fas fa-share-alt"></i></template>{{ $ts.integration }}</FormLink>
</div> <FormLink :active="page === 'instance-block'" replace to="/instance/instance-block"><template #icon><i class="fas fa-ban"></i></template>{{ $ts.instanceBlocking }}</FormLink>
<div v-if="page === 'logs'" class="_section"> <FormLink :active="page === 'proxy-account'" replace to="/instance/proxy-account"><template #icon><i class="fas fa-ghost"></i></template>{{ $ts.proxyAccount }}</FormLink>
<MkFolder> <FormLink :active="page === 'other-settings'" replace to="/instance/other-settings"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.other }}</FormLink>
<template #header><i class="fas fa-stream"></i> {{ $ts.logs }}</template> </FormGroup>
</FormBase>
<div class="_keyValue" v-for="log in modLogs"> </div>
<b>{{ log.type }}</b><span>by {{ log.user.username }}</span><MkTime :time="log.createdAt" style="opacity: 0.7;"/> <div class="main">
</div> <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
</MkFolder> </div>
</div>
<div v-if="page === 'metrics'">
<XMetrics/>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, markRaw } from 'vue'; import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
import VueJsonPretty from 'vue-json-pretty'; import { i18n } from '@client/i18n';
import MkInstanceStats from '@client/components/instance-stats.vue'; import FormLink from '@client/components/form/link.vue';
import MkButton from '@client/components/ui/button.vue'; import FormGroup from '@client/components/form/group.vue';
import MkSelect from '@client/components/ui/select.vue'; import FormBase from '@client/components/form/base.vue';
import MkInput from '@client/components/ui/input.vue'; import FormButton from '@client/components/form/button.vue';
import MkContainer from '@client/components/ui/container.vue'; import { scroll } from '@client/scripts/scroll';
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 * as symbols from '@client/symbols'; import * as symbols from '@client/symbols';
import * as os from '@client/os';
import { lookupUser } from '@client/scripts/lookup-user';
export default defineComponent({ export default defineComponent({
components: { components: {
MkInstanceStats, FormBase,
MkButton, FormLink,
MkSelect, FormGroup,
MkInput, FormButton,
MkContainer,
MkFolder,
XMetrics,
VueJsonPretty,
}, },
data() { props: {
return { initialPage: {
[symbols.PAGE_INFO]: { type: String,
tabs: [{ required: false
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,
} }
}, },
computed: { setup(props, context) {
meta() { const indexInfo = {
return this.$instance; title: i18n.locale.instance,
}, icon: 'fas fa-cog'
}, };
const INFO = ref(indexInfo);
mounted() { const page = ref(props.initialPage);
this.fetchJobs(); const narrow = ref(false);
this.fetchModLogs(); const view = ref(null);
const el = ref(null);
os.api('admin/server-info', {}).then(res => { const onInfo = (viewInfo) => {
this.serverInfo = res; INFO.value = viewInfo;
}); };
const pageProps = ref({});
os.api('admin/get-table-stats', {}).then(res => { const component = computed(() => {
this.dbInfo = Object.entries(res).sort((a, b) => b[1].size - a[1].size); if (page.value == null) return null;
}); switch (page.value) {
}, case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
case 'users': return defineAsyncComponent(() => import('./users.vue'));
methods: { case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
async showInstanceInfo(q) { case 'federation': return defineAsyncComponent(() => import('./federation.vue'));
let instance = q; case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
if (typeof q === 'string') { case 'files': return defineAsyncComponent(() => import('./files.vue'));
instance = await os.api('federation/show-instance', { case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
host: q 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() { watch(component, () => {
os.api('admin/queue/deliver-delayed', {}).then(jobs => { pageProps.value = {};
this.jobs = jobs;
nextTick(() => {
scroll(el.value, 0);
}); });
}, }, { immediate: true });
fetchModLogs() { watch(() => props.initialPage, () => {
os.api('admin/show-moderation-logs', {}).then(logs => { if (props.initialPage == null && !narrow.value) {
this.modLogs = logs; 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> </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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,101 +1,52 @@
<template> <template>
<div> <div class="_formItem">
<MkFolder> <div class="_formLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
<template #header><i class="fas fa-heartbeat"></i> {{ $ts.metrics }}</template> <div class="_formPanel xhexznfu">
<div class="_section" style="padding: 0 var(--margin);"> <div>
<div class="_content"> <canvas :ref="cpumem"></canvas>
<MkContainer :foldable="false" class="_gap"> </div>
<template #header><i class="fas fa-microchip"></i>{{ $ts.cpuAndMemory }}</template> <div v-if="serverInfo">
<!-- <div class="_table">
<template #func> <div class="_row">
<button class="_button" @click="resume" :disabled="!paused"><i class="fas fa-play"></i></button> <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
<button class="_button" @click="pause" :disabled="paused"><i class="fas fa-pause"></i></button> <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
</template> <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 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> </div>
</div> </div>
</MkFolder> </div>
</div>
<MkFolder> <div class="_formItem">
<template #header><i class="fas fa-clipboard-list"></i> {{ $ts.jobQueue }}</template> <div class="_formLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
<div class="_formPanel xhexznfu">
<div class="vkyrmkwb" :style="{ gridTemplateRows: queueHeight }"> <div>
<MkContainer :foldable="false" :scrollable="true" :resize-base-el="() => $el"> <canvas :ref="disk"></canvas>
<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>
</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> </div>
</template> </template>
@ -188,9 +139,11 @@ export default defineComponent({
}, },
beforeUnmount() { beforeUnmount() {
this.connection.off('stats', this.onStats); if (this.connection) {
this.connection.off('statsLog', this.onStatsLog); this.connection.off('stats', this.onStats);
this.connection.dispose(); this.connection.off('statsLog', this.onStatsLog);
this.connection.dispose();
}
this.queueConnection.dispose(); this.queueConnection.dispose();
}, },
@ -232,9 +185,9 @@ export default defineComponent({
aspectRatio: 3, aspectRatio: 3,
layout: { layout: {
padding: { padding: {
left: 0, left: 16,
right: 0, right: 16,
top: 8, top: 16,
bottom: 0 bottom: 0
} }
}, },
@ -304,9 +257,9 @@ export default defineComponent({
aspectRatio: 3, aspectRatio: 3,
layout: { layout: {
padding: { padding: {
left: 0, left: 16,
right: 0, right: 16,
top: 8, top: 16,
bottom: 0 bottom: 0
} }
}, },
@ -375,9 +328,9 @@ export default defineComponent({
aspectRatio: 3, aspectRatio: 3,
layout: { layout: {
padding: { padding: {
left: 0, left: 16,
right: 0, right: 16,
top: 8, top: 16,
bottom: 0 bottom: 0
} }
}, },
@ -494,81 +447,9 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.xhexznfu { .xhexznfu {
&.min-width_1000px { > div:nth-child(2) {
.sboqnrfi { padding: 16px;
display: grid; border-top: solid 0.5px var(--divider);
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);
}
} }
} }
</style> </style>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,27 +1,29 @@
<template> <template>
<section class="_section"> <div class="_formItem">
<div class="_title"><slot name="title"></slot></div> <div class="_formLabel"><slot name="title"></slot></div>
<div class="_content _table"> <div class="_formPanel pumxzjhg">
<div class="_row"> <div class="_table status">
<div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> <div class="_row">
<div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
<div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
<div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
</div> <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</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> </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> </div>
</section> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -110,10 +112,10 @@ export default defineComponent({
aspectRatio: 3, aspectRatio: 3,
layout: { layout: {
padding: { padding: {
left: 0, left: 16,
right: 0, right: 16,
top: 8, top: 16,
bottom: 0 bottom: 12
} }
}, },
legend: { legend: {
@ -198,3 +200,19 @@ export default defineComponent({
} }
}); });
</script> </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>

View File

@ -1,43 +1,47 @@
<template> <template>
<div> <FormBase>
<XQueue :connection="connection" domain="inbox"> <XQueue :connection="connection" domain="inbox">
<template #title><i class="fas fa-exchange-alt"></i> In</template> <template #title>In</template>
</XQueue> </XQueue>
<XQueue :connection="connection" domain="deliver"> <XQueue :connection="connection" domain="deliver">
<template #title><i class="fas fa-exchange-alt"></i> Out</template> <template #title>Out</template>
</XQueue> </XQueue>
<section class="_section"> <FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton>
<div class="_content"> </FormBase>
<MkButton @click="clear()"><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</MkButton>
</div>
</section>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import MkButton from '@client/components/ui/button.vue'; import MkButton from '@client/components/ui/button.vue';
import XQueue from './queue.chart.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 os from '@client/os';
import * as symbols from '@client/symbols'; import * as symbols from '@client/symbols';
export default defineComponent({ export default defineComponent({
components: { components: {
FormBase,
FormButton,
MkButton, MkButton,
XQueue, XQueue,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
title: this.$ts.jobQueue, title: this.$ts.jobQueue,
icon: 'fas fa-exchange-alt', icon: 'fas fa-clipboard-list',
}, },
connection: os.stream.useSharedConnection('queueStats'), connection: os.stream.useSharedConnection('queueStats'),
} }
}, },
mounted() { mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
this.$nextTick(() => { this.$nextTick(() => {
this.connection.send('requestLog', { this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8), id: Math.random().toString().substr(2, 8),

View File

@ -1,44 +1,41 @@
<template> <template>
<div class="relaycxt"> <FormBase class="relaycxt">
<section class="_section add"> <FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton>
<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>
<section class="_section relays"> <div class="_formItem" v-for="relay in relays" :key="relay.inbox">
<div class="_title"><i class="fas fa-project-diagram"></i> {{ $ts.addedRelays }}</div> <div class="_formPanel" style="padding: 16px;">
<div class="_content relay" v-for="relay in relays" :key="relay.inbox">
<div>{{ relay.inbox }}</div> <div>{{ relay.inbox }}</div>
<div>{{ $t(`_relayStatus.${relay.status}`) }}</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> </div>
</section> </div>
</div> </FormBase>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import MkButton from '@client/components/ui/button.vue'; import MkButton from '@client/components/ui/button.vue';
import MkInput from '@client/components/ui/input.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 os from '@client/os';
import * as symbols from '@client/symbols'; import * as symbols from '@client/symbols';
export default defineComponent({ export default defineComponent({
components: { components: {
FormBase,
FormButton,
MkButton, MkButton,
MkInput, MkInput,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
title: this.$ts.relays, title: this.$ts.relays,
icon: 'fas fa-project-diagram', icon: 'fas fa-globe',
}, },
relays: [], relays: [],
inbox: '', inbox: '',
@ -49,8 +46,19 @@ export default defineComponent({
this.refresh(); this.refresh();
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { 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', { os.api('admin/relays/add', {
inbox inbox
}).then((relay: any) => { }).then((relay: any) => {
@ -86,9 +94,5 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
._content.relay {
div {
margin: 0.5em 0;
}
}
</style> </style>

View File

@ -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>

View File

@ -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>

View File

@ -1,581 +1,132 @@
<template> <template>
<div v-if="meta" class="_section"> <FormBase>
<section class="_card _gap"> <FormSuspense :p="init">
<div class="_title"><i class="fas fa-info-circle"></i> {{ $ts.basicInfo }}</div> <FormInput v-model:value="name">
<div class="_content"> <span>{{ $ts.instanceName }}</span>
<MkInput v-model:value="name">{{ $ts.instanceName }}</MkInput> </FormInput>
<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>
<MkInput v-model:value="pinnedClipId">{{ $ts.pinnedClipId }}</MkInput> <FormTextarea v-model:value="description">
<span>{{ $ts.instanceDescription }}</span>
</FormTextarea>
<section class="_card _gap"> <FormInput v-model:value="iconUrl">
<div class="_content"> <template #prefix><i class="fas fa-link"></i></template>
<MkInput v-model:value="maxNoteTextLength" type="number" :save="() => save()"><template #icon><i class="fas fa-pencil-alt"></i></template>{{ $ts.maxNoteTextLength }}</MkInput> <span>{{ $ts.iconUrl }}</span>
</div> </FormInput>
<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>
<section class="_card _gap"> <FormInput v-model:value="bannerUrl">
<div class="_title"><i class="fas fa-user"></i> {{ $ts.registration }}</div> <template #prefix><i class="fas fa-link"></i></template>
<div class="_content"> <span>{{ $ts.bannerUrl }}</span>
<MkSwitch v-model:value="enableRegistration" @update:value="save()">{{ $ts.enableRegistration }}</MkSwitch> </FormInput>
<MkButton v-if="!enableRegistration" @click="invite">{{ $ts.invite }}</MkButton>
</div>
</section>
<section class="_card _gap"> <FormInput v-model:value="tosUrl">
<div class="_title"><i class="fas fa-shield-alt"></i> {{ $ts.hcaptcha }}</div> <template #prefix><i class="fas fa-link"></i></template>
<div class="_content"> <span>{{ $ts.tosUrl }}</span>
<MkSwitch v-model:value="enableHcaptcha">{{ $ts.enableHcaptcha }}</MkSwitch> </FormInput>
<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>
<section class="_card _gap"> <FormInput v-model:value="maintainerName">
<div class="_title"><i class="fas fa-shield-alt"></i> {{ $ts.recaptcha }}</div> <span>{{ $ts.maintainerName }}</span>
<div class="_content"> </FormInput>
<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>
<section class="_card _gap"> <FormInput v-model:value="maintainerEmail" type="email">
<div class="_title"><i class="fas fa-envelope"></i> {{ $ts.emailConfig }}</div> <template #prefix><i class="fas fa-envelope"></i></template>
<div class="_content"> <span>{{ $ts.maintainerEmail }}</span>
<MkSwitch v-model:value="enableEmail" @update:value="save()">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></MkSwitch> </FormInput>
<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>
<section class="_card _gap"> <FormInput v-model:value="maxNoteTextLength" type="number">
<div class="_title"><i class="fas fa-bolt"></i> {{ $ts.serviceworker }}</div> <template #prefix><i class="fas fa-pencil-alt"></i></template>
<div class="_content"> <span>{{ $ts.maxNoteTextLength }}</span>
<MkSwitch v-model:value="enableServiceWorker">{{ $ts.enableServiceworker }}<template #desc>{{ $ts.serviceworkerInfo }}</template></MkSwitch> </FormInput>
<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>
<section class="_card _gap"> <FormSwitch v-model:value="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch>
<div class="_title"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedUsers }}</div> <FormSwitch v-model:value="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch>
<div class="_content"> <FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo>
<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>
<section class="_card _gap"> <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
<div class="_title"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedPages }}</div> </FormSuspense>
<div class="_content"> </FormBase>
<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>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue'; import { defineComponent } from 'vue';
import MkButton from '@client/components/ui/button.vue'; import FormSwitch from '@client/components/form/switch.vue';
import MkInput from '@client/components/ui/input.vue'; import FormInput from '@client/components/form/input.vue';
import MkTextarea from '@client/components/ui/textarea.vue'; import FormButton from '@client/components/form/button.vue';
import MkSwitch from '@client/components/ui/switch.vue'; import FormBase from '@client/components/form/base.vue';
import MkInfo from '@client/components/ui/info.vue'; import FormGroup from '@client/components/form/group.vue';
import { url } from '@client/config'; import FormTextarea from '@client/components/form/textarea.vue';
import getAcct from '@/misc/acct/render'; import FormInfo from '@client/components/form/info.vue';
import FormSuspense from '@client/components/form/suspense.vue';
import * as os from '@client/os'; import * as os from '@client/os';
import { fetchInstance } from '@client/instance';
import * as symbols from '@client/symbols'; import * as symbols from '@client/symbols';
import { fetchInstance } from '@client/instance';
export default defineComponent({ export default defineComponent({
components: { components: {
MkButton, FormSwitch,
MkInput, FormInput,
MkTextarea, FormBase,
MkSwitch, FormGroup,
MkInfo, FormButton,
Captcha: defineAsyncComponent(() => import('@client/components/captcha.vue')), FormTextarea,
FormInfo,
FormSuspense,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
title: this.$ts.instance, title: this.$ts.general,
icon: 'fas fa-cog', 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, name: null,
description: null, description: null,
tosUrl: null as string | null, tosUrl: null as string | null,
enableEmail: false, maintainerName: null,
email: null, maintainerEmail: null,
bannerUrl: null,
iconUrl: null, iconUrl: null,
logoImageUrl: null, bannerUrl: null,
backgroundImageUrl: null,
maxNoteTextLength: 0, maxNoteTextLength: 0,
enableRegistration: false,
enableLocalTimeline: false, enableLocalTimeline: false,
enableGlobalTimeline: 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() { async mounted() {
this.meta = await os.api('meta', { detail: true }); this.$emit('info', this[symbols.PAGE_INFO]);
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;
});
}
});
}, },
methods: { methods: {
invite() { async init() {
os.api('admin/invite').then(x => { const meta = await os.api('meta', { detail: true });
os.dialog({ this.name = meta.name;
type: 'info', this.description = meta.description;
text: x.code this.tosUrl = meta.tosUrl;
}); this.iconUrl = meta.iconUrl;
}).catch(e => { this.bannerUrl = meta.bannerUrl;
os.dialog({ this.maintainerName = meta.maintainerName;
type: 'error', this.maintainerEmail = meta.maintainerEmail;
text: e this.maxNoteTextLength = meta.maxNoteTextLength;
}); this.enableLocalTimeline = !meta.disableLocalTimeline;
}); this.enableGlobalTimeline = !meta.disableGlobalTimeline;
}, },
addPinUser() { save() {
os.selectUser().then(user => { os.apiWithDialog('admin/update-meta', {
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', {
name: this.name, name: this.name,
description: this.description, description: this.description,
tosUrl: this.tosUrl, tosUrl: this.tosUrl,
bannerUrl: this.bannerUrl,
iconUrl: this.iconUrl, iconUrl: this.iconUrl,
logoImageUrl: this.logoImageUrl, bannerUrl: this.bannerUrl,
backgroundImageUrl: this.backgroundImageUrl,
maintainerName: this.maintainerName, maintainerName: this.maintainerName,
maintainerEmail: this.maintainerEmail, maintainerEmail: this.maintainerEmail,
maxNoteTextLength: this.maxNoteTextLength, maxNoteTextLength: this.maxNoteTextLength,
disableRegistration: !this.enableRegistration,
disableLocalTimeline: !this.enableLocalTimeline, disableLocalTimeline: !this.enableLocalTimeline,
disableGlobalTimeline: !this.enableGlobalTimeline, 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(() => { }).then(() => {
fetchInstance(); fetchInstance();
if (withDialog) {
os.success();
}
}).catch(e => {
os.dialog({
type: 'error',
text: e
});
}); });
} }
} }

View File

@ -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>

View File

@ -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>

View File

@ -1,86 +1,71 @@
<template> <template>
<div class="mk-instance-users"> <div class="lknzcolw">
<div class="_section"> <div class="actions">
<div class="_content"> <MkButton inline primary @click="addUser()"><i class="fas fa-plus"></i> {{ $ts.addUser }}</MkButton>
<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> </div>
<div class="_section lookup"> <div class="users">
<div class="_title"><i class="fas fa-search"></i> {{ $ts.lookup }}</div> <div class="inputs" style="display: flex;">
<div class="_content"> <MkSelect v-model:value="sort" style="margin: 0; flex: 1;">
<MkInput class="target" v-model:value="target" type="text" @enter="showUser()"> <template #label>{{ $ts.sort }}</template>
<span>{{ $ts.usernameOrUserId }}</span> <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> </MkInput>
<MkButton @click="showUser()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
</div> </div>
</div>
<div class="_section users"> <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users">
<div class="_title"><i class="fas fa-users"></i> {{ $ts.users }}</div> <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)">
<div class="_content"> <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
<div class="inputs" style="display: flex;"> <div class="body">
<MkSelect v-model:value="sort" style="margin: 0; flex: 1;"> <header>
<template #label>{{ $ts.sort }}</template> <MkUserName class="name" :user="user"/>
<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> <span class="acct">@{{ acct(user) }}</span>
<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span>
<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span>
<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span>
</MkSelect> <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span>
<MkSelect v-model:value="state" style="margin: 0; flex: 1;"> </header>
<template #label>{{ $ts.state }}</template> <div>
<option value="all">{{ $ts.all }}</option> <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
<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>
</div> </div>
</button> <div>
</MkPagination> <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
</div> </div>
</div>
</button>
</MkPagination>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { defineComponent } from 'vue';
import parseAcct from '@/misc/acct/parse';
import MkButton from '@client/components/ui/button.vue'; import MkButton from '@client/components/ui/button.vue';
import MkInput from '@client/components/ui/input.vue'; import MkInput from '@client/components/ui/input.vue';
import MkSelect from '@client/components/ui/select.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 { acct } from '../../filters/user';
import * as os from '@client/os'; import * as os from '@client/os';
import * as symbols from '@client/symbols'; import * as symbols from '@client/symbols';
import { lookupUser } from '@client/scripts/lookup-user';
export default defineComponent({ export default defineComponent({
components: { components: {
@ -97,6 +83,8 @@ export default defineComponent({
MkPagination, MkPagination,
}, },
emits: ['info'],
data() { data() {
return { return {
[symbols.PAGE_INFO]: { [symbols.PAGE_INFO]: {
@ -107,7 +95,6 @@ export default defineComponent({
handler: this.searchUser handler: this.searchUser
} }
}, },
target: '',
sort: '+createdAt', sort: '+createdAt',
state: 'all', state: 'all',
origin: 'local', origin: 'local',
@ -140,40 +127,12 @@ export default defineComponent({
}, },
}, },
methods: { async mounted() {
/** テキストエリアのユーザーを解決する */ this.$emit('info', this[symbols.PAGE_INFO]);
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();
});
});
},
/** テキストエリアから処理対象ユーザーを設定する */ methods: {
async showUser() { lookupUser,
const user = await this.fetchUser();
this.show(user);
this.target = '';
},
searchUser() { searchUser() {
os.selectUser().then(user => { os.selectUser().then(user => {
@ -203,9 +162,7 @@ export default defineComponent({
}, },
show(user) { show(user) {
os.popup(import('./user-dialog.vue'), { os.pageWindow(`/instance/user/${user.id}`);
userId: user.id
}, {}, 'closed');
}, },
acct acct
@ -214,57 +171,61 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-instance-users { .lknzcolw {
> .actions {
margin: var(--margin);
}
> .users { > .users {
> ._content { margin: var(--margin);
> .users {
margin-top: var(--margin); > .users {
margin-top: var(--margin);
> .user { > .user {
display: flex; display: flex;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
text-align: left; text-align: left;
align-items: center; align-items: center;
padding: 16px; padding: 16px;
&:hover { &:hover {
color: var(--accent); 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 { > header {
width: 60px; > .name {
height: 60px; font-weight: bold;
}
> .body {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
@media (max-width: 500px) {
font-size: 14px;
} }
> header { > .acct {
> .name { margin-left: 8px;
font-weight: bold; opacity: 0.7;
} }
> .acct { > .staff {
margin-left: 8px; margin-left: 0.5em;
opacity: 0.7; color: var(--badge);
} }
> .staff { > .punished {
margin-left: 0.5em; margin-left: 0.5em;
color: var(--badge); color: #4dabf7;
}
> .punished {
margin-left: 0.5em;
color: #4dabf7;
}
} }
} }
} }

View File

@ -1,34 +1,36 @@
<template> <template>
<FormBase> <FormBase>
<FormGroup v-if="user"> <FormSuspense :p="init">
<template #label><MkAcct :user="user"/></template>
<FormKeyValueView>
<template #key>ID</template>
<template #value><span class="_monospace">{{ user.id }}</span></template>
</FormKeyValueView>
<FormGroup> <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> <FormKeyValueView>
<template #key>{{ $ts.updatedAt }}</template> <template #key>ID</template>
<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> <template #value><span class="_monospace">{{ user.id }}</span></template>
</FormKeyValueView> </FormKeyValueView>
</FormGroup>
<FormObjectView tall :value="user"> <FormGroup>
<span>Raw</span> <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink>
</FormObjectView>
</FormGroup> <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> </FormBase>
</template> </template>
@ -80,23 +82,27 @@ export default defineComponent({
} }
} : undefined].filter(x => x !== undefined) : [], } : undefined].filter(x => x !== undefined) : [],
})), })),
init: null,
user: null, user: null,
} }
}, },
mounted() { watch: {
this.fetch(); userId: {
handler() {
this.init = () => os.api('users/show', {
userId: this.userId
}).then(user => {
this.user = user;
});
},
immediate: true
}
}, },
methods: { methods: {
number, number,
bytes, bytes,
async fetch() {
this.user = await os.api('users/show', {
userId: this.userId
});
}
} }
}); });
</script> </script>

View File

@ -195,7 +195,7 @@
<template v-if="page === 'index'"> <template v-if="page === 'index'">
<div> <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"/> <XNote v-for="note in user.pinnedNotes" class="note _block" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/>
</div> </div>
<MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo> <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>

View File

@ -59,17 +59,9 @@ export const router = createRouter({
{ path: '/my/antennas', component: page('my-antennas/index') }, { path: '/my/antennas', component: page('my-antennas/index') },
{ path: '/my/clips', component: page('my-clips/index') }, { path: '/my/clips', component: page('my-clips/index') },
{ path: '/scratchpad', component: page('scratchpad') }, { 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', 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: '/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: '/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 }) }, { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) },

View File

@ -124,7 +124,13 @@ export function getUserMenu(user) {
action: () => { action: () => {
copyToClipboard(`@${user.username}@${user.host || host}`); 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', icon: 'fas fa-info-circle',
text: i18n.locale.info, text: i18n.locale.info,
action: () => { action: () => {

View File

@ -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();
});
}

View File

@ -25,9 +25,9 @@
</component> </component>
</template> </template>
<div class="divider"></div> <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> <i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span>
</button> </MkA>
<button class="item _button" @click="more"> <button class="item _button" @click="more">
<i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> <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> <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) { more(ev) {
os.popup(import('@client/components/launch-pad.vue'), {}, { os.popup(import('@client/components/launch-pad.vue'), {}, {
}, 'closed'); }, 'closed');

View File

@ -20,9 +20,9 @@
</component> </component>
</template> </template>
<div class="divider"></div> <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> <i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span>
</button> </MkA>
<button class="item _button" @click="more"> <button class="item _button" @click="more">
<i class="fas fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> <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> <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) { more(ev) {
os.popup(import('@client/components/launch-pad.vue'), {}, { os.popup(import('@client/components/launch-pad.vue'), {}, {
}, 'closed'); }, 'closed');