summaryrefslogtreecommitdiff
path: root/packages/client/src/pages/admin
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-13 12:23:49 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-13 12:23:49 +0900
commit2795fe457909c687f668d020ef65d52abc3182fb (patch)
tree0a52e4e4d854333496fcc487560c93c3de5d5eb5 /packages/client/src/pages/admin
parentMerge branch 'develop' (diff)
parent12.96.0 (diff)
downloadmisskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.gz
misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.bz2
misskey-2795fe457909c687f668d020ef65d52abc3182fb.zip
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages/admin')
-rw-r--r--packages/client/src/pages/admin/abuses.vue170
-rw-r--r--packages/client/src/pages/admin/ads.vue138
-rw-r--r--packages/client/src/pages/admin/announcements.vue125
-rw-r--r--packages/client/src/pages/admin/bot-protection.vue138
-rw-r--r--packages/client/src/pages/admin/database.vue61
-rw-r--r--packages/client/src/pages/admin/email-settings.vue128
-rw-r--r--packages/client/src/pages/admin/emoji-edit-dialog.vue120
-rw-r--r--packages/client/src/pages/admin/emojis.vue263
-rw-r--r--packages/client/src/pages/admin/file-dialog.vue129
-rw-r--r--packages/client/src/pages/admin/files-settings.vue93
-rw-r--r--packages/client/src/pages/admin/files.vue209
-rw-r--r--packages/client/src/pages/admin/index.vue388
-rw-r--r--packages/client/src/pages/admin/instance-block.vue72
-rw-r--r--packages/client/src/pages/admin/instance.vue291
-rw-r--r--packages/client/src/pages/admin/integrations-discord.vue85
-rw-r--r--packages/client/src/pages/admin/integrations-github.vue85
-rw-r--r--packages/client/src/pages/admin/integrations-twitter.vue85
-rw-r--r--packages/client/src/pages/admin/integrations.vue74
-rw-r--r--packages/client/src/pages/admin/metrics.vue472
-rw-r--r--packages/client/src/pages/admin/object-storage.vue155
-rw-r--r--packages/client/src/pages/admin/other-settings.vue83
-rw-r--r--packages/client/src/pages/admin/overview.vue236
-rw-r--r--packages/client/src/pages/admin/proxy-account.vue87
-rw-r--r--packages/client/src/pages/admin/queue.chart.vue102
-rw-r--r--packages/client/src/pages/admin/queue.vue73
-rw-r--r--packages/client/src/pages/admin/relays.vue99
-rw-r--r--packages/client/src/pages/admin/security.vue83
-rw-r--r--packages/client/src/pages/admin/service-worker.vue85
-rw-r--r--packages/client/src/pages/admin/settings.vue151
-rw-r--r--packages/client/src/pages/admin/users.vue254
30 files changed, 4534 insertions, 0 deletions
diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
new file mode 100644
index 0000000000..ca94737781
--- /dev/null
+++ b/packages/client/src/pages/admin/abuses.vue
@@ -0,0 +1,170 @@
+<template>
+<div class="lcixvhis">
+ <div class="_section reports">
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <MkSelect v-model="state" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="unresolved">{{ $ts.unresolved }}</option>
+ <option value="resolved">{{ $ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporteeOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporterOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <!-- TODO
+ <div class="inputs" style="display: flex; padding-top: 1.2em;">
+ <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()">
+ <span>{{ $ts.username }}</span>
+ </MkInput>
+ <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'">
+ <span>{{ $ts.host }}</span>
+ </MkInput>
+ </div>
+ -->
+
+ <MkPagination :pagination="pagination" #default="{items}" ref="reports" style="margin-top: var(--margin);">
+ <div class="bcekxzvu _card _gap" v-for="report in items" :key="report.id">
+ <div class="_content target">
+ <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/>
+ <div class="info">
+ <MkUserName class="name" :user="report.targetUser"/>
+ <div class="acct">@{{ acct(report.targetUser) }}</div>
+ </div>
+ </div>
+ <div class="_content">
+ <div>
+ <Mfm :text="report.comment"/>
+ </div>
+ <hr>
+ <div>Reporter: <MkAcct :user="report.reporter"/></div>
+ <div><MkTime :time="report.createdAt"/></div>
+ </div>
+ <div class="_footer">
+ <div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div>
+ <MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $ts.abuseMarkAsResolved }}</MkButton>
+ </div>
+ </div>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.abuseReports,
+ icon: 'fas fa-exclamation-circle',
+ bg: 'var(--bg)',
+ },
+ searchUsername: '',
+ searchHost: '',
+ state: 'unresolved',
+ reporterOrigin: 'combined',
+ targetUserOrigin: 'combined',
+ pagination: {
+ endpoint: 'admin/abuse-user-reports',
+ limit: 10,
+ params: () => ({
+ state: this.state,
+ reporterOrigin: this.reporterOrigin,
+ targetUserOrigin: this.targetUserOrigin,
+ }),
+ },
+ }
+ },
+
+ watch: {
+ state() {
+ this.$refs.reports.reload();
+ },
+
+ reporterOrigin() {
+ this.$refs.reports.reload();
+ },
+
+ targetUserOrigin() {
+ this.$refs.reports.reload();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ acct,
+
+ resolve(report) {
+ os.apiWithDialog('admin/resolve-abuse-user-report', {
+ reportId: report.id,
+ }).then(() => {
+ this.$refs.reports.removeItem(item => item.id === report.id);
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lcixvhis {
+ margin: var(--margin);
+}
+
+.bcekxzvu {
+ > .target {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ > .avatar {
+ width: 42px;
+ height: 42px;
+ }
+
+ > .info {
+ margin-left: 0.3em;
+ padding: 0 8px;
+ flex: 1;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
new file mode 100644
index 0000000000..df6c9d5d00
--- /dev/null
+++ b/packages/client/src/pages/admin/ads.vue
@@ -0,0 +1,138 @@
+<template>
+<div class="uqshojas">
+ <section class="_card _gap ads" v-for="ad in ads">
+ <div class="_content ad">
+ <MkAd v-if="ad.url" :specify="ad"/>
+ <MkInput v-model="ad.url" type="url">
+ <template #label>URL</template>
+ </MkInput>
+ <MkInput v-model="ad.imageUrl">
+ <template #label>{{ $ts.imageUrl }}</template>
+ </MkInput>
+ <div style="margin: 32px 0;">
+ <MkRadio v-model="ad.place" value="square">square</MkRadio>
+ <MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio>
+ <MkRadio v-model="ad.place" value="horizontal-big">horizontal-big</MkRadio>
+ </div>
+ <!--
+ <div style="margin: 32px 0;">
+ {{ $ts.priority }}
+ <MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio>
+ </div>
+ -->
+ <MkInput v-model="ad.ratio" type="number">
+ <template #label>{{ $ts.ratio }}</template>
+ </MkInput>
+ <MkInput v-model="ad.expiresAt" type="date">
+ <template #label>{{ $ts.expiration }}</template>
+ </MkInput>
+ <MkTextarea v-model="ad.memo">
+ <template #label>{{ $ts.memo }}</template>
+ </MkTextarea>
+ <div class="buttons">
+ <MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkRadio,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.ads,
+ icon: 'fas fa-audio-description',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.add,
+ handler: this.add,
+ }],
+ },
+ ads: [],
+ }
+ },
+
+ created() {
+ os.api('admin/ad/list').then(ads => {
+ this.ads = ads;
+ });
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ add() {
+ this.ads.unshift({
+ id: null,
+ memo: '',
+ place: 'square',
+ priority: 'middle',
+ ratio: 1,
+ url: '',
+ imageUrl: null,
+ expiresAt: null,
+ });
+ },
+
+ remove(ad) {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: ad.url }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.ads = this.ads.filter(x => x != ad);
+ os.apiWithDialog('admin/ad/delete', {
+ id: ad.id
+ });
+ });
+ },
+
+ save(ad) {
+ if (ad.id == null) {
+ os.apiWithDialog('admin/ad/create', {
+ ...ad,
+ expiresAt: new Date(ad.expiresAt).getTime()
+ });
+ } else {
+ os.apiWithDialog('admin/ad/update', {
+ ...ad,
+ expiresAt: new Date(ad.expiresAt).getTime()
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.uqshojas {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
new file mode 100644
index 0000000000..a64008967f
--- /dev/null
+++ b/packages/client/src/pages/admin/announcements.vue
@@ -0,0 +1,125 @@
+<template>
+<div class="ztgjmzrw">
+ <section class="_card _gap announcements" v-for="announcement in announcements">
+ <div class="_content announcement">
+ <MkInput v-model="announcement.title">
+ <template #label>{{ $ts.title }}</template>
+ </MkInput>
+ <MkTextarea v-model="announcement.text">
+ <template #label>{{ $ts.text }}</template>
+ </MkTextarea>
+ <MkInput v-model="announcement.imageUrl">
+ <template #label>{{ $ts.imageUrl }}</template>
+ </MkInput>
+ <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
+ <div class="buttons">
+ <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.announcements,
+ icon: 'fas fa-broadcast-tower',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.add,
+ handler: this.add,
+ }],
+ },
+ announcements: [],
+ }
+ },
+
+ created() {
+ os.api('admin/announcements/list').then(announcements => {
+ this.announcements = announcements;
+ });
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ add() {
+ this.announcements.unshift({
+ id: null,
+ title: '',
+ text: '',
+ imageUrl: null
+ });
+ },
+
+ remove(announcement) {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: announcement.title }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.announcements = this.announcements.filter(x => x != announcement);
+ os.api('admin/announcements/delete', announcement);
+ });
+ },
+
+ save(announcement) {
+ if (announcement.id == null) {
+ os.api('admin/announcements/create', announcement).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.saved
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ } else {
+ os.api('admin/announcements/update', announcement).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.saved
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
new file mode 100644
index 0000000000..8f7873baa3
--- /dev/null
+++ b/packages/client/src/pages/admin/bot-protection.vue
@@ -0,0 +1,138 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormRadios v-model="provider">
+ <template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template>
+ <option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option>
+ <option value="hcaptcha">hCaptcha</option>
+ <option value="recaptcha">reCAPTCHA</option>
+ </FormRadios>
+
+ <template v-if="provider === 'hcaptcha'">
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">hCaptcha</div>
+ <div class="main">
+ <FormInput v-model="hcaptchaSiteKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.hcaptchaSiteKey }}</span>
+ </FormInput>
+ <FormInput v-model="hcaptchaSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.hcaptchaSecretKey }}</span>
+ </FormInput>
+ </div>
+ </div>
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.preview }}</div>
+ <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
+ <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="provider === 'recaptcha'">
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">reCAPTCHA</div>
+ <div class="main">
+ <FormInput v-model="recaptchaSiteKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.recaptchaSiteKey }}</span>
+ </FormInput>
+ <FormInput v-model="recaptchaSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.recaptchaSecretKey }}</span>
+ </FormInput>
+ </div>
+ </div>
+ <div v-if="recaptchaSiteKey" class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.preview }}</div>
+ <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
+ <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 '@/components/debobigego/radios.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormRadios,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormInfo,
+ FormSuspense,
+ MkCaptcha: defineAsyncComponent(() => import('@/components/captcha.vue')),
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.botProtection,
+ icon: 'fas fa-shield-alt'
+ },
+ provider: null,
+ enableHcaptcha: false,
+ hcaptchaSiteKey: null,
+ hcaptchaSecretKey: null,
+ enableRecaptcha: false,
+ recaptchaSiteKey: null,
+ recaptchaSecretKey: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableHcaptcha = meta.enableHcaptcha;
+ this.hcaptchaSiteKey = meta.hcaptchaSiteKey;
+ this.hcaptchaSecretKey = meta.hcaptchaSecretKey;
+ this.enableRecaptcha = meta.enableRecaptcha;
+ this.recaptchaSiteKey = meta.recaptchaSiteKey;
+ this.recaptchaSecretKey = meta.recaptchaSecretKey;
+
+ this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null;
+
+ this.$watch(() => this.provider, () => {
+ this.enableHcaptcha = this.provider === 'hcaptcha';
+ this.enableRecaptcha = this.provider === 'recaptcha';
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableHcaptcha: this.enableHcaptcha,
+ hcaptchaSiteKey: this.hcaptchaSiteKey,
+ hcaptchaSecretKey: this.hcaptchaSecretKey,
+ enableRecaptcha: this.enableRecaptcha,
+ recaptchaSiteKey: this.recaptchaSiteKey,
+ recaptchaSecretKey: this.recaptchaSecretKey,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
new file mode 100644
index 0000000000..b550831e02
--- /dev/null
+++ b/packages/client/src/pages/admin/database.vue
@@ -0,0 +1,61 @@
+<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 '@/components/debobigego/suspense.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import bytes from '@/filters/bytes';
+import number from '@/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',
+ bg: 'var(--bg)',
+ },
+ databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ bytes, number,
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
new file mode 100644
index 0000000000..3733f53a23
--- /dev/null
+++ b/packages/client/src/pages/admin/email-settings.vue
@@ -0,0 +1,128 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch>
+
+ <template v-if="enableEmail">
+ <FormInput v-model="email" type="email">
+ <span>{{ $ts.emailAddress }}</span>
+ </FormInput>
+
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.smtpConfig }}</div>
+ <div class="main">
+ <FormInput v-model="smtpHost">
+ <span>{{ $ts.smtpHost }}</span>
+ </FormInput>
+ <FormInput v-model="smtpPort" type="number">
+ <span>{{ $ts.smtpPort }}</span>
+ </FormInput>
+ <FormInput v-model="smtpUser">
+ <span>{{ $ts.smtpUser }}</span>
+ </FormInput>
+ <FormInput v-model="smtpPass" type="password">
+ <span>{{ $ts.smtpPass }}</span>
+ </FormInput>
+ <FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo>
+ <FormSwitch v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ enableEmail: false,
+ email: null,
+ smtpSecure: false,
+ smtpHost: '',
+ smtpPort: 0,
+ smtpUser: '',
+ smtpPass: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableEmail = meta.enableEmail;
+ this.email = meta.email;
+ this.smtpSecure = meta.smtpSecure;
+ this.smtpHost = meta.smtpHost;
+ this.smtpPort = meta.smtpPort;
+ this.smtpUser = meta.smtpUser;
+ this.smtpPass = meta.smtpPass;
+ },
+
+ async testEmail() {
+ const { canceled, result: destination } = await os.dialog({
+ title: this.$ts.destination,
+ input: {
+ placeholder: this.$instance.maintainerEmail
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('admin/send-email', {
+ to: destination,
+ subject: 'Test email',
+ text: 'Yo'
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableEmail: this.enableEmail,
+ email: this.email,
+ smtpSecure: this.smtpSecure,
+ smtpHost: this.smtpHost,
+ smtpPort: this.smtpPort,
+ smtpUser: this.smtpUser,
+ smtpPass: this.smtpPass,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/emoji-edit-dialog.vue b/packages/client/src/pages/admin/emoji-edit-dialog.vue
new file mode 100644
index 0000000000..e612855105
--- /dev/null
+++ b/packages/client/src/pages/admin/emoji-edit-dialog.vue
@@ -0,0 +1,120 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ :with-ok-button="true"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+ @ok="ok()"
+>
+ <template #header>:{{ emoji.name }}:</template>
+
+ <div class="_monolithic_">
+ <div class="yigymqpb _section">
+ <img :src="emoji.url" class="img"/>
+ <MkInput class="_formBlock" v-model="name">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="category" :datalist="categories">
+ <template #label>{{ $ts.category }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="aliases">
+ <template #label>{{ $ts.tags }}</template>
+ <template #caption>{{ $ts.setMultipleBySeparatingWithSpace }}</template>
+ </MkInput>
+ <MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+import { unique } from '@/scripts/array';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkButton,
+ MkInput,
+ },
+
+ props: {
+ emoji: {
+ required: true,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ name: this.emoji.name,
+ category: this.emoji.category,
+ aliases: this.emoji.aliases?.join(' '),
+ categories: [],
+ }
+ },
+
+ created() {
+ os.api('meta', { detail: false }).then(({ emojis }) => {
+ this.categories = unique(emojis.map((x: any) => x.category || '').filter((x: string) => x !== ''));
+ });
+ },
+
+ methods: {
+ ok() {
+ this.update();
+ },
+
+ async update() {
+ await os.apiWithDialog('admin/emoji/update', {
+ id: this.emoji.id,
+ name: this.name,
+ category: this.category,
+ aliases: this.aliases.split(' '),
+ });
+
+ this.$emit('done', {
+ updated: {
+ name: this.name,
+ category: this.category,
+ aliases: this.aliases.split(' '),
+ }
+ });
+ this.$refs.dialog.close();
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.emoji.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.api('admin/emoji/remove', {
+ id: this.emoji.id
+ }).then(() => {
+ this.$emit('done', {
+ deleted: true
+ });
+ this.$refs.dialog.close();
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yigymqpb {
+ > .img {
+ display: block;
+ height: 64px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
new file mode 100644
index 0000000000..c9ba193dd1
--- /dev/null
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -0,0 +1,263 @@
+<template>
+<div class="ogwlenmc">
+ <div class="local" v-if="tab === 'local'">
+ <MkInput v-model="query" :debounce="true" type="search" style="margin: var(--margin);">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkPagination :pagination="pagination" ref="emojis">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <div class="name _monospace">{{ emoji.name }}</div>
+ <div class="info">{{ emoji.category }}</div>
+ </div>
+ </button>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+
+ <div class="remote" v-else-if="tab === 'remote'">
+ <MkInput v-model="queryRemote" :debounce="true" type="search" style="margin: var(--margin);">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkInput v-model="host" :debounce="true" style="margin: var(--margin);">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ <MkPagination :pagination="remotePagination" ref="remoteEmojis">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <div class="name _monospace">{{ emoji.name }}</div>
+ <div class="info">{{ emoji.host }}</div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, toRef } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkTab from '@/components/tab.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkTab,
+ MkButton,
+ MkInput,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.customEmojis,
+ icon: 'fas fa-laugh',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.addEmoji,
+ handler: this.add,
+ }],
+ tabs: [{
+ active: this.tab === 'local',
+ title: this.$ts.local,
+ onClick: () => { this.tab = 'local'; },
+ }, {
+ active: this.tab === 'remote',
+ title: this.$ts.remote,
+ onClick: () => { this.tab = 'remote'; },
+ },]
+ })),
+ tab: 'local',
+ query: null,
+ queryRemote: null,
+ host: '',
+ pagination: {
+ endpoint: 'admin/emoji/list',
+ limit: 30,
+ params: computed(() => ({
+ query: (this.query && this.query !== '') ? this.query : null
+ }))
+ },
+ remotePagination: {
+ endpoint: 'admin/emoji/list-remote',
+ limit: 30,
+ params: computed(() => ({
+ query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null,
+ host: (this.host && this.host !== '') ? this.host : null
+ }))
+ },
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', toRef(this, symbols.PAGE_INFO));
+ },
+
+ methods: {
+ async add(e) {
+ const files = await selectFile(e.currentTarget || e.target, null, true);
+
+ const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
+ fileId: file.id,
+ })));
+ promise.then(() => {
+ this.$refs.emojis.reload();
+ });
+ os.promiseDialog(promise);
+ },
+
+ edit(emoji) {
+ os.popup(import('./emoji-edit-dialog.vue'), {
+ emoji: emoji
+ }, {
+ done: result => {
+ if (result.updated) {
+ this.$refs.emojis.replaceItem(item => item.id === emoji.id, {
+ ...emoji,
+ ...result.updated
+ });
+ } else if (result.deleted) {
+ this.$refs.emojis.removeItem(item => item.id === emoji.id);
+ }
+ },
+ }, 'closed');
+ },
+
+ im(emoji) {
+ os.apiWithDialog('admin/emoji/copy', {
+ emojiId: emoji.id,
+ });
+ },
+
+ remoteMenu(emoji, ev) {
+ os.popupMenu([{
+ type: 'label',
+ text: ':' + emoji.name + ':',
+ }, {
+ text: this.$ts.import,
+ icon: 'fas fa-plus',
+ action: () => { this.im(emoji) }
+ }], ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ogwlenmc {
+ > .local {
+ .empty {
+ margin: var(--margin);
+ }
+
+ .ldhfsamy {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .img {
+ width: 42px;
+ height: 42px;
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+ }
+
+ > .remote {
+ .empty {
+ margin: var(--margin);
+ }
+
+ .ldhfsamy {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .img {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ font-size: 90%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue
new file mode 100644
index 0000000000..016a012ea5
--- /dev/null
+++ b/packages/client/src/pages/admin/file-dialog.vue
@@ -0,0 +1,129 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header v-if="file">{{ file.name }}</template>
+ <div class="cxqhhsmd" v-if="file">
+ <div class="_section">
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ <div class="info">
+ <span style="margin-right: 1em;">{{ file.type }}</span>
+ <span>{{ bytes(file.size) }}</span>
+ <MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
+ </div>
+ </div>
+ <div class="_section">
+ <div class="_content">
+ <MkSwitch @update:modelValue="toggleIsSensitive" v-model="isSensitive">NSFW</MkSwitch>
+ </div>
+ </div>
+ <div class="_section">
+ <div class="_content">
+ <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
+ <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+ <div class="_section" v-if="info">
+ <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 '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import Progress from '@/scripts/loading';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSwitch,
+ XModalWindow,
+ MkDriveFileThumbnail,
+ },
+
+ props: {
+ fileId: {
+ required: true,
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ file: null,
+ info: null,
+ isSensitive: false,
+ };
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ async fetch() {
+ Progress.start();
+ this.file = await os.api('drive/files/show', { fileId: this.fileId });
+ this.info = await os.api('admin/drive/show-file', { fileId: this.fileId });
+ this.isSensitive = this.file.isSensitive;
+ Progress.done();
+ },
+
+ showUser() {
+ os.pageWindow(`/user-info/${this.file.userId}`);
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.file.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('drive/files/delete', {
+ fileId: this.file.id
+ });
+ },
+
+ async toggleIsSensitive(v) {
+ await os.api('drive/files/update', { fileId: this.fileId, isSensitive: v });
+ this.isSensitive = v;
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cxqhhsmd {
+ > ._section {
+ > .thumbnail {
+ height: 150px;
+ max-width: 100%;
+ }
+
+ > .info {
+ text-align: center;
+ margin-top: 8px;
+ }
+
+ > .rawdata {
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/files-settings.vue b/packages/client/src/pages/admin/files-settings.vue
new file mode 100644
index 0000000000..03d8f3de1f
--- /dev/null
+++ b/packages/client/src/pages/admin/files-settings.vue
@@ -0,0 +1,93 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="cacheRemoteFiles">
+ {{ $ts.cacheRemoteFiles }}
+ <template #desc>{{ $ts.cacheRemoteFilesDescription }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="proxyRemoteFiles">
+ {{ $ts.proxyRemoteFiles }}
+ <template #desc>{{ $ts.proxyRemoteFilesDescription }}</template>
+ </FormSwitch>
+
+ <FormInput v-model="localDriveCapacityMb" type="number">
+ <span>{{ $ts.driveCapacityPerLocalAccount }}</span>
+ <template #suffix>MB</template>
+ <template #desc>{{ $ts.inMb }}</template>
+ </FormInput>
+
+ <FormInput v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ cacheRemoteFiles: false,
+ proxyRemoteFiles: false,
+ localDriveCapacityMb: 0,
+ remoteDriveCapacityMb: 0,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.cacheRemoteFiles = meta.cacheRemoteFiles;
+ this.proxyRemoteFiles = meta.proxyRemoteFiles;
+ this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
+ this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ cacheRemoteFiles: this.cacheRemoteFiles,
+ proxyRemoteFiles: this.proxyRemoteFiles,
+ localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
+ remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
new file mode 100644
index 0000000000..e291d97bbc
--- /dev/null
+++ b/packages/client/src/pages/admin/files.vue
@@ -0,0 +1,209 @@
+<template>
+<div class="xrmjdkdw">
+ <MkContainer :foldable="true" class="lookup">
+ <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template>
+ <div class="xrmjdkdw-lookup">
+ <MkInput class="item" v-model="q" type="text" @enter="find()">
+ <template #label>{{ $ts.fileIdOrUrl }}</template>
+ </MkInput>
+ <MkButton @click="find()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
+ </div>
+ </MkContainer>
+
+ <div class="_section">
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <MkSelect v-model="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>
+ <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ </div>
+ <div class="inputs" style="display: flex; padding-top: 1.2em;">
+ <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>MIME type</template>
+ </MkInput>
+ </div>
+ <MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files">
+ <button class="file _panel _button _gap" v-for="file in items" :key="file.id" @click="show(file, $event)">
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ <div class="body">
+ <div>
+ <small style="opacity: 0.7;">{{ file.name }}</small>
+ </div>
+ <div>
+ <MkAcct v-if="file.user" :user="file.user"/>
+ <div v-else>{{ $ts.system }}</div>
+ </div>
+ <div>
+ <span style="margin-right: 1em;">{{ file.type }}</span>
+ <span>{{ bytes(file.size) }}</span>
+ </div>
+ <div>
+ <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+ </div>
+ </div>
+ </button>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ MkContainer,
+ MkDriveFileThumbnail,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.files,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ actions: [{
+ text: this.$ts.clearCachedFiles,
+ icon: 'fas fa-trash-alt',
+ handler: this.clear
+ }]
+ },
+ q: null,
+ origin: 'local',
+ type: null,
+ searchHost: '',
+ pagination: {
+ endpoint: 'admin/drive/files',
+ limit: 10,
+ params: () => ({
+ type: (this.type && this.type !== '') ? this.type : null,
+ origin: this.origin,
+ hostname: (this.hostname && this.hostname !== '') ? this.hostname : null,
+ }),
+ },
+ }
+ },
+
+ watch: {
+ type() {
+ this.$refs.files.reload();
+ },
+ origin() {
+ this.$refs.files.reload();
+ },
+ searchHost() {
+ this.$refs.files.reload();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ clear() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.clearCachedFilesConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/drive/clean-remote-files', {});
+ });
+ },
+
+ show(file, ev) {
+ os.popup(import('./file-dialog.vue'), {
+ fileId: file.id
+ }, {}, 'closed');
+ },
+
+ find() {
+ os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => {
+ this.show(file);
+ }).catch(e => {
+ if (e.code === 'NO_SUCH_FILE') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.notFound
+ });
+ }
+ });
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xrmjdkdw {
+ margin: var(--margin);
+
+ > .lookup {
+ margin-bottom: 16px;
+ }
+
+ .urempief {
+ margin-top: var(--margin);
+
+ > .file {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .thumbnail {
+ width: 128px;
+ height: 128px;
+ }
+
+ > .body {
+ margin-left: 0.3em;
+ padding: 8px;
+ flex: 1;
+
+ @media (max-width: 500px) {
+ font-size: 14px;
+ }
+ }
+ }
+ }
+}
+
+.xrmjdkdw-lookup {
+ padding: 16px;
+
+ > .item {
+ margin-bottom: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
new file mode 100644
index 0000000000..d3f9406db7
--- /dev/null
+++ b/packages/client/src/pages/admin/index.vue
@@ -0,0 +1,388 @@
+<template>
+<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el">
+ <div class="nav" v-if="!narrow || page == null">
+ <MkHeader :info="header"></MkHeader>
+
+ <MkSpacer :content-max="700">
+ <div class="lxpfedzu">
+ <div class="banner">
+ <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
+ </div>
+
+ <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+
+ <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu>
+ </div>
+ </MkSpacer>
+ </div>
+ <div class="main">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
+ <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
+ </MkStickyContainer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent, isRef, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkSuperMenu from '@/components/ui/super-menu.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import MkInfo from '@/components/ui/info.vue';
+import { scroll } from '@/scripts/scroll';
+import { instance } from '@/instance';
+import * as symbols from '@/symbols';
+import * as os from '@/os';
+import { lookupUser } from '@/scripts/lookup-user';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ MkSuperMenu,
+ FormGroup,
+ FormButton,
+ MkInfo,
+ },
+
+ provide: {
+ shouldOmitHeaderTitle: false,
+ },
+
+ props: {
+ initialPage: {
+ type: String,
+ required: false
+ }
+ },
+
+ setup(props, context) {
+ const indexInfo = {
+ title: i18n.locale.controlPanel,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ hideHeader: true,
+ };
+ const INFO = ref(indexInfo);
+ const childInfo = ref(null);
+ const page = ref(props.initialPage);
+ const narrow = ref(false);
+ const view = ref(null);
+ const el = ref(null);
+ const onInfo = (viewInfo) => {
+ if (isRef(viewInfo)) {
+ watch(viewInfo, () => {
+ childInfo.value = viewInfo.value;
+ }, { immediate: true });
+ } else {
+ childInfo.value = viewInfo;
+ }
+ };
+ const pageProps = ref({});
+
+ const isEmpty = (x: any) => x == null || x == '';
+
+ const noMaintainerInformation = ref(false);
+ const noBotProtection = ref(false);
+
+ os.api('meta', { detail: true }).then(meta => {
+ // TODO: 設定が完了しても残ったままになるので、ストリーミングでmeta更新イベントを受け取ってよしなに更新する
+ noMaintainerInformation.value = isEmpty(meta.maintainerName) || isEmpty(meta.maintainerEmail);
+ noBotProtection.value = !meta.enableHcaptcha && !meta.enableRecaptcha;
+ });
+
+ const menuDef = computed(() => [{
+ title: i18n.locale.quickAction,
+ items: [{
+ type: 'button',
+ icon: 'fas fa-search',
+ text: i18n.locale.lookup,
+ action: lookup,
+ }, ...(instance.disableRegistration ? [{
+ type: 'button',
+ icon: 'fas fa-user',
+ text: i18n.locale.invite,
+ action: invite,
+ }] : [])],
+ }, {
+ title: i18n.locale.administration,
+ items: [{
+ icon: 'fas fa-tachometer-alt',
+ text: i18n.locale.dashboard,
+ to: '/admin/overview',
+ active: page.value === 'overview',
+ }, {
+ icon: 'fas fa-users',
+ text: i18n.locale.users,
+ to: '/admin/users',
+ active: page.value === 'users',
+ }, {
+ icon: 'fas fa-laugh',
+ text: i18n.locale.customEmojis,
+ to: '/admin/emojis',
+ active: page.value === 'emojis',
+ }, {
+ icon: 'fas fa-globe',
+ text: i18n.locale.federation,
+ to: '/admin/federation',
+ active: page.value === 'federation',
+ }, {
+ icon: 'fas fa-clipboard-list',
+ text: i18n.locale.jobQueue,
+ to: '/admin/queue',
+ active: page.value === 'queue',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.files,
+ to: '/admin/files',
+ active: page.value === 'files',
+ }, {
+ icon: 'fas fa-broadcast-tower',
+ text: i18n.locale.announcements,
+ to: '/admin/announcements',
+ active: page.value === 'announcements',
+ }, {
+ icon: 'fas fa-audio-description',
+ text: i18n.locale.ads,
+ to: '/admin/ads',
+ active: page.value === 'ads',
+ }, {
+ icon: 'fas fa-exclamation-circle',
+ text: i18n.locale.abuseReports,
+ to: '/admin/abuses',
+ active: page.value === 'abuses',
+ }],
+ }, {
+ title: i18n.locale.settings,
+ items: [{
+ icon: 'fas fa-cog',
+ text: i18n.locale.general,
+ to: '/admin/settings',
+ active: page.value === 'settings',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.files,
+ to: '/admin/files-settings',
+ active: page.value === 'files-settings',
+ }, {
+ icon: 'fas fa-envelope',
+ text: i18n.locale.emailServer,
+ to: '/admin/email-settings',
+ active: page.value === 'email-settings',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.objectStorage,
+ to: '/admin/object-storage',
+ active: page.value === 'object-storage',
+ }, {
+ icon: 'fas fa-lock',
+ text: i18n.locale.security,
+ to: '/admin/security',
+ active: page.value === 'security',
+ }, {
+ icon: 'fas fa-bolt',
+ text: 'ServiceWorker',
+ to: '/admin/service-worker',
+ active: page.value === 'service-worker',
+ }, {
+ icon: 'fas fa-globe',
+ text: i18n.locale.relays,
+ to: '/admin/relays',
+ active: page.value === 'relays',
+ }, {
+ icon: 'fas fa-share-alt',
+ text: i18n.locale.integration,
+ to: '/admin/integrations',
+ active: page.value === 'integrations',
+ }, {
+ icon: 'fas fa-ban',
+ text: i18n.locale.instanceBlocking,
+ to: '/admin/instance-block',
+ active: page.value === 'instance-block',
+ }, {
+ icon: 'fas fa-ghost',
+ text: i18n.locale.proxyAccount,
+ to: '/admin/proxy-account',
+ active: page.value === 'proxy-account',
+ }, {
+ icon: 'fas fa-cogs',
+ text: i18n.locale.other,
+ to: '/admin/other-settings',
+ active: page.value === 'other-settings',
+ }],
+ }, {
+ title: i18n.locale.info,
+ items: [{
+ icon: 'fas fa-database',
+ text: i18n.locale.database,
+ to: '/admin/database',
+ active: page.value === 'database',
+ }],
+ }]);
+ const component = computed(() => {
+ if (page.value == null) return null;
+ switch (page.value) {
+ case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
+ case 'users': return defineAsyncComponent(() => import('./users.vue'));
+ case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
+ case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
+ case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
+ case 'files': return defineAsyncComponent(() => import('./files.vue'));
+ case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
+ case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
+ case 'database': return defineAsyncComponent(() => import('./database.vue'));
+ case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
+ case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
+ case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue'));
+ case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue'));
+ case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue'));
+ case 'security': return defineAsyncComponent(() => import('./security.vue'));
+ case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue'));
+ case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue'));
+ case 'relays': return defineAsyncComponent(() => import('./relays.vue'));
+ case 'integrations': return defineAsyncComponent(() => import('./integrations.vue'));
+ case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue'));
+ case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue'));
+ case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue'));
+ case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue'));
+ case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue'));
+ case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue'));
+ }
+ });
+
+ watch(component, () => {
+ pageProps.value = {};
+
+ nextTick(() => {
+ scroll(el.value, { top: 0 });
+ });
+ }, { immediate: true });
+
+ watch(() => props.initialPage, () => {
+ if (props.initialPage == null && !narrow.value) {
+ page.value = 'overview';
+ } else {
+ page.value = props.initialPage;
+ if (props.initialPage == null) {
+ INFO.value = indexInfo;
+ }
+ }
+ });
+
+ onMounted(() => {
+ narrow.value = el.value.offsetWidth < 800;
+ if (!narrow.value) {
+ page.value = 'overview';
+ }
+ });
+
+ const invite = () => {
+ os.api('admin/invite').then(x => {
+ os.dialog({
+ type: 'info',
+ text: x.code
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ };
+
+ const lookup = (ev) => {
+ os.popupMenu([{
+ 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);
+ };
+
+ return {
+ [symbols.PAGE_INFO]: INFO,
+ menuDef,
+ header: {
+ title: i18n.locale.controlPanel,
+ },
+ noMaintainerInformation,
+ noBotProtection,
+ page,
+ narrow,
+ view,
+ el,
+ onInfo,
+ childInfo,
+ pageProps,
+ component,
+ invite,
+ lookup,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hiyeyicy {
+ &.wide {
+ display: flex;
+ margin: 0 auto;
+ height: 100%;
+
+ > .nav {
+ width: 32%;
+ max-width: 280px;
+ box-sizing: border-box;
+ border-right: solid 0.5px var(--divider);
+ overflow: auto;
+ height: 100%;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+
+ > .nav {
+ .lxpfedzu {
+ > .info {
+ margin: 16px 0;
+ }
+
+ > .banner {
+ margin: 16px;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 42px;
+ border-radius: 8px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
new file mode 100644
index 0000000000..f5b249698d
--- /dev/null
+++ b/packages/client/src/pages/admin/instance-block.vue
@@ -0,0 +1,72 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormTextarea v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ blockedHosts: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.blockedHosts = meta.blockedHosts.join('\n');
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ blockedHosts: this.blockedHosts.split('\n') || [],
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/instance.vue b/packages/client/src/pages/admin/instance.vue
new file mode 100644
index 0000000000..26eefe243f
--- /dev/null
+++ b/packages/client/src/pages/admin/instance.vue
@@ -0,0 +1,291 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="520"
+ :height="500"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ instance.host }}</template>
+ <div class="mk-instance-info">
+ <div class="_table section">
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.software }}</div>
+ <div class="_data">{{ instance.softwareName || '?' }}</div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.version }}</div>
+ <div class="_data">{{ instance.softwareVersion || '?' }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="_table data section">
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.registeredAt }}</div>
+ <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<MkTime :time="instance.caughtAt"/>)</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.following }}</div>
+ <button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.followers }}</div>
+ <button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.users }}</div>
+ <button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.notes }}</div>
+ <div class="_data">{{ number(instance.notesCount) }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.files }}</div>
+ <div class="_data">{{ number(instance.driveFiles) }}</div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.storageUsage }}</div>
+ <div class="_data">{{ bytes(instance.driveUsage) }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestRequestSentAt }}</div>
+ <div class="_data"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestStatus }}</div>
+ <div class="_data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestRequestReceivedAt }}</div>
+ <div class="_data"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+ </div>
+ </div>
+ </div>
+ <div class="chart">
+ <div class="header">
+ <span class="label">{{ $ts.charts }}</span>
+ <div class="selects">
+ <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+ <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
+ <option value="instance-users">{{ $ts._instanceCharts.users }}</option>
+ <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
+ <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
+ <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
+ <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
+ <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
+ <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
+ <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
+ <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
+ <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
+ </MkSelect>
+ <MkSelect v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $ts.perHour }}</option>
+ <option value="day">{{ $ts.perDay }}</option>
+ </MkSelect>
+ </div>
+ </div>
+ <div class="chart">
+ <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
+ </div>
+ </div>
+ <div class="operations section">
+ <span class="label">{{ $ts.operations }}</span>
+ <MkSwitch v-model="isSuspended" class="switch">{{ $ts.stopActivityDelivery }}</MkSwitch>
+ <MkSwitch :model-value="isBlocked" class="switch" @update:modelValue="changeBlock">{{ $ts.blockThisInstance }}</MkSwitch>
+ <details>
+ <summary>{{ $ts.deleteAllFiles }}</summary>
+ <MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton>
+ </details>
+ <details>
+ <summary>{{ $ts.removeAllFollowing }}</summary>
+ <MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-minus-circle"></i> {{ $ts.removeAllFollowing }}</MkButton>
+ <MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo>
+ </details>
+ </div>
+ <details class="metadata section">
+ <summary class="label">{{ $ts.metadata }}</summary>
+ <pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre>
+ </details>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInfo from '@/components/ui/info.vue';
+import MkChart from '@/components/chart.vue';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkSelect,
+ MkButton,
+ MkSwitch,
+ MkInfo,
+ MkChart,
+ },
+
+ props: {
+ instance: {
+ type: Object,
+ required: true
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ isSuspended: this.instance.isSuspended,
+ chartSrc: 'requests',
+ chartSpan: 'hour',
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+
+ isBlocked() {
+ return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host);
+ }
+ },
+
+ watch: {
+ isSuspended() {
+ os.api('admin/federation/update-instance', {
+ host: this.instance.host,
+ isSuspended: this.isSuspended
+ });
+ },
+ },
+
+ methods: {
+ changeBlock(e) {
+ os.api('admin/update-meta', {
+ blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
+ });
+ },
+
+ removeAllFollowing() {
+ os.apiWithDialog('admin/federation/remove-all-following', {
+ host: this.instance.host
+ });
+ },
+
+ deleteAllFiles() {
+ os.apiWithDialog('admin/federation/delete-all-files', {
+ host: this.instance.host
+ });
+ },
+
+ showFollowing() {
+ // TODO: ページ遷移
+ },
+
+ showFollowers() {
+ // TODO: ページ遷移
+ },
+
+ showUsers() {
+ // TODO: ページ遷移
+ },
+
+ bytes,
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-info {
+ overflow: auto;
+
+ > .section {
+ padding: 16px 32px;
+
+ @media (max-width: 500px) {
+ padding: 8px 16px;
+ }
+
+ &:not(:first-child) {
+ border-top: solid 0.5px var(--divider);
+ }
+ }
+
+ > .chart {
+ border-top: solid 0.5px var(--divider);
+ padding: 16px 0 12px 0;
+
+ > .header {
+ padding: 0 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .selects {
+ display: flex;
+ }
+ }
+
+ > .chart {
+ padding: 0 16px;
+
+ @media (max-width: 500px) {
+ padding: 0;
+ }
+ }
+ }
+
+ > .operations {
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .switch {
+ margin: 16px 0;
+ }
+ }
+
+ > .metadata {
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > pre > code {
+ display: block;
+ max-height: 200px;
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/integrations-discord.vue b/packages/client/src/pages/admin/integrations-discord.vue
new file mode 100644
index 0000000000..81e47499c6
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-discord.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableDiscordIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableDiscordIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo>
+
+ <FormInput v-model="discordClientId">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client ID
+ </FormInput>
+
+ <FormInput v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Discord',
+ icon: 'fab fa-discord'
+ },
+ enableDiscordIntegration: false,
+ discordClientId: null,
+ discordClientSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ this.discordClientId = meta.discordClientId;
+ this.discordClientSecret = meta.discordClientSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableDiscordIntegration: this.enableDiscordIntegration,
+ discordClientId: this.discordClientId,
+ discordClientSecret: this.discordClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations-github.vue b/packages/client/src/pages/admin/integrations-github.vue
new file mode 100644
index 0000000000..2bbc3ae9a1
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-github.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableGithubIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableGithubIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo>
+
+ <FormInput v-model="githubClientId">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client ID
+ </FormInput>
+
+ <FormInput v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'GitHub',
+ icon: 'fab fa-github'
+ },
+ enableGithubIntegration: false,
+ githubClientId: null,
+ githubClientSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ this.githubClientId = meta.githubClientId;
+ this.githubClientSecret = meta.githubClientSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableGithubIntegration: this.enableGithubIntegration,
+ githubClientId: this.githubClientId,
+ githubClientSecret: this.githubClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations-twitter.vue b/packages/client/src/pages/admin/integrations-twitter.vue
new file mode 100644
index 0000000000..19ed216ab9
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-twitter.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableTwitterIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableTwitterIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo>
+
+ <FormInput v-model="twitterConsumerKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Consumer Key
+ </FormInput>
+
+ <FormInput v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Twitter',
+ icon: 'fab fa-twitter'
+ },
+ enableTwitterIntegration: false,
+ twitterConsumerKey: null,
+ twitterConsumerSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.twitterConsumerKey = meta.twitterConsumerKey;
+ this.twitterConsumerSecret = meta.twitterConsumerSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableTwitterIntegration: this.enableTwitterIntegration,
+ twitterConsumerKey: this.twitterConsumerKey,
+ twitterConsumerSecret: this.twitterConsumerSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
new file mode 100644
index 0000000000..c21eebc1c6
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations.vue
@@ -0,0 +1,74 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormLink to="/admin/integrations/twitter">
+ <i class="fab fa-twitter"></i> Twitter
+ <template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ <FormLink to="/admin/integrations/github">
+ <i class="fab fa-github"></i> GitHub
+ <template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ <FormLink to="/admin/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 '@/components/debobigego/link.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ enableTwitterIntegration: false,
+ enableGithubIntegration: false,
+ enableDiscordIntegration: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/metrics.vue b/packages/client/src/pages/admin/metrics.vue
new file mode 100644
index 0000000000..05b64b235c
--- /dev/null
+++ b/packages/client/src/pages/admin/metrics.vue
@@ -0,0 +1,472 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="cpumem"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
+ <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="disk"></canvas>
+ </div>
+ <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="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="net"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle
+} from 'chart.js';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkwFederation from '../../widgets/federation.vue';
+import { version, url } from '@/config';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkInstanceInfo from './instance.vue';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle
+);
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSelect,
+ MkInput,
+ MkContainer,
+ MkFolder,
+ MkwFederation,
+ },
+
+ data() {
+ return {
+ version,
+ url,
+ stats: null,
+ serverInfo: null,
+ connection: null,
+ queueConnection: markRaw(os.stream.useChannel('queueStats')),
+ memUsage: 0,
+ chartCpuMem: null,
+ chartNet: null,
+ jobs: [],
+ logs: [],
+ logLevel: 'all',
+ logDomain: '',
+ modLogs: [],
+ dbInfo: null,
+ overviewHeight: '1fr',
+ queueHeight: '1fr',
+ paused: false,
+ }
+ },
+
+ computed: {
+ gridColor() {
+ // TODO: var(--panel)の色が暗いか明るいかで判定する
+ return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+ },
+ },
+
+ mounted() {
+ this.fetchJobs();
+
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ os.api('admin/server-info', {}).then(res => {
+ this.serverInfo = res;
+
+ this.connection = markRaw(os.stream.useChannel('serverStats'));
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 150
+ });
+
+ this.$nextTick(() => {
+ this.queueConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ });
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ this.connection.dispose();
+ }
+ this.queueConnection.dispose();
+ },
+
+ methods: {
+ cpumem(el) {
+ if (this.chartCpuMem != null) return;
+ this.chartCpuMem = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'CPU',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#86b300',
+ backgroundColor: alpha('#86b300', 0.1),
+ data: []
+ }, {
+ label: 'MEM (active)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ backgroundColor: alpha('#935dbf', 0.02),
+ data: []
+ }, {
+ label: 'MEM (used)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ borderDash: [5, 5],
+ fill: false,
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ max: 100
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ net(el) {
+ if (this.chartNet != null) return;
+ this.chartNet = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'In',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Out',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ disk(el) {
+ if (this.chartDisk != null) return;
+ this.chartDisk = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Read',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Write',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ fetchJobs() {
+ os.api('admin/queue/deliver-delayed', {}).then(jobs => {
+ this.jobs = jobs;
+ });
+ },
+
+ onStats(stats) {
+ if (this.paused) return;
+
+ const cpu = (stats.cpu * 100).toFixed(0);
+ const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+ const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+ this.memUsage = stats.mem.active;
+
+ this.chartCpuMem.data.labels.push('');
+ this.chartCpuMem.data.datasets[0].data.push(cpu);
+ this.chartCpuMem.data.datasets[1].data.push(memActive);
+ this.chartCpuMem.data.datasets[2].data.push(memUsed);
+ this.chartNet.data.labels.push('');
+ this.chartNet.data.datasets[0].data.push(stats.net.rx);
+ this.chartNet.data.datasets[1].data.push(stats.net.tx);
+ this.chartDisk.data.labels.push('');
+ this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+ this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+ if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+ this.chartCpuMem.data.labels.shift();
+ this.chartCpuMem.data.datasets[0].data.shift();
+ this.chartCpuMem.data.datasets[1].data.shift();
+ this.chartCpuMem.data.datasets[2].data.shift();
+ this.chartNet.data.labels.shift();
+ this.chartNet.data.datasets[0].data.shift();
+ this.chartNet.data.datasets[1].data.shift();
+ this.chartDisk.data.labels.shift();
+ this.chartDisk.data.datasets[0].data.shift();
+ this.chartDisk.data.datasets[1].data.shift();
+ }
+ this.chartCpuMem.update();
+ this.chartNet.update();
+ this.chartDisk.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of [...statsLog].reverse()) {
+ this.onStats(stats);
+ }
+ },
+
+ bytes,
+
+ number,
+
+ pause() {
+ this.paused = true;
+ },
+
+ resume() {
+ this.paused = false;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xhexznfu {
+ > div:nth-child(2) {
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
new file mode 100644
index 0000000000..0f1431c258
--- /dev/null
+++ b/packages/client/src/pages/admin/object-storage.vue
@@ -0,0 +1,155 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch>
+
+ <template v-if="useObjectStorage">
+ <FormInput v-model="objectStorageBaseUrl">
+ <span>{{ $ts.objectStorageBaseUrl }}</span>
+ <template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageBucket">
+ <span>{{ $ts.objectStorageBucket }}</span>
+ <template #desc>{{ $ts.objectStorageBucketDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStoragePrefix">
+ <span>{{ $ts.objectStoragePrefix }}</span>
+ <template #desc>{{ $ts.objectStoragePrefixDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageEndpoint">
+ <span>{{ $ts.objectStorageEndpoint }}</span>
+ <template #desc>{{ $ts.objectStorageEndpointDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageRegion">
+ <span>{{ $ts.objectStorageRegion }}</span>
+ <template #desc>{{ $ts.objectStorageRegionDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageAccessKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>Access key</span>
+ </FormInput>
+
+ <FormInput v-model="objectStorageSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>Secret key</span>
+ </FormInput>
+
+ <FormSwitch v-model="objectStorageUseSSL">
+ {{ $ts.objectStorageUseSSL }}
+ <template #desc>{{ $ts.objectStorageUseSSLDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageUseProxy">
+ {{ $ts.objectStorageUseProxy }}
+ <template #desc>{{ $ts.objectStorageUseProxyDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageSetPublicRead">
+ {{ $ts.objectStorageSetPublicRead }}
+ </FormSwitch>
+
+ <FormSwitch v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ useObjectStorage: false,
+ objectStorageBaseUrl: null,
+ objectStorageBucket: null,
+ objectStoragePrefix: null,
+ objectStorageEndpoint: null,
+ objectStorageRegion: null,
+ objectStoragePort: null,
+ objectStorageAccessKey: null,
+ objectStorageSecretKey: null,
+ objectStorageUseSSL: false,
+ objectStorageUseProxy: false,
+ objectStorageSetPublicRead: false,
+ objectStorageS3ForcePathStyle: true,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.useObjectStorage = meta.useObjectStorage;
+ this.objectStorageBaseUrl = meta.objectStorageBaseUrl;
+ this.objectStorageBucket = meta.objectStorageBucket;
+ this.objectStoragePrefix = meta.objectStoragePrefix;
+ this.objectStorageEndpoint = meta.objectStorageEndpoint;
+ this.objectStorageRegion = meta.objectStorageRegion;
+ this.objectStoragePort = meta.objectStoragePort;
+ this.objectStorageAccessKey = meta.objectStorageAccessKey;
+ this.objectStorageSecretKey = meta.objectStorageSecretKey;
+ this.objectStorageUseSSL = meta.objectStorageUseSSL;
+ this.objectStorageUseProxy = meta.objectStorageUseProxy;
+ this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
+ this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ useObjectStorage: this.useObjectStorage,
+ objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null,
+ objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null,
+ objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null,
+ objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null,
+ objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null,
+ objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null,
+ objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null,
+ objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
+ objectStorageUseSSL: this.objectStorageUseSSL,
+ objectStorageUseProxy: this.objectStorageUseProxy,
+ objectStorageSetPublicRead: this.objectStorageSetPublicRead,
+ objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
new file mode 100644
index 0000000000..e8f872bf0a
--- /dev/null
+++ b/packages/client/src/pages/admin/other-settings.vue
@@ -0,0 +1,83 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormGroup>
+ <FormInput v-model="summalyProxy">
+ <template #prefix><i class="fas fa-link"></i></template>
+ Summaly Proxy URL
+ </FormInput>
+ </FormGroup>
+ <FormGroup>
+ <FormInput v-model="deeplAuthKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ DeepL Auth Key
+ </FormInput>
+ <FormSwitch v-model="deeplIsPro">
+ Pro account
+ </FormSwitch>
+ </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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ summalyProxy: '',
+ deeplAuthKey: '',
+ deeplIsPro: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.summalyProxy = meta.summalyProxy;
+ this.deeplAuthKey = meta.deeplAuthKey;
+ this.deeplIsPro = meta.deeplIsPro;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ summalyProxy: this.summalyProxy,
+ deeplAuthKey: this.deeplAuthKey,
+ deeplIsPro: this.deeplIsPro,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
new file mode 100644
index 0000000000..e1352945a1
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.vue
@@ -0,0 +1,236 @@
+<template>
+<div class="edbbcaef" v-size="{ max: [740] }">
+ <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)">
+ <div class="number _panel">
+ <div class="label">Users</div>
+ <div class="value _monospace">
+ {{ number(stats.originalUsersCount) }}
+ <MkNumberDiff v-if="usersComparedToThePrevDay != null" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Notes</div>
+ <div class="value _monospace">
+ {{ number(stats.originalNotesCount) }}
+ <MkNumberDiff v-if="notesComparedToThePrevDay != null" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ </div>
+
+ <MkContainer :foldable="true" class="charts">
+ <template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template>
+ <div style="padding-top: 12px;">
+ <MkInstanceStats :chart-limit="500" :detailed="true"/>
+ </div>
+ </MkContainer>
+
+ <div class="queue">
+ <MkContainer :foldable="true" :thin="true" class="deliver">
+ <template #header>Queue: deliver</template>
+ <MkQueueChart :connection="queueStatsConnection" domain="deliver"/>
+ </MkContainer>
+ <MkContainer :foldable="true" :thin="true" class="inbox">
+ <template #header>Queue: inbox</template>
+ <MkQueueChart :connection="queueStatsConnection" domain="inbox"/>
+ </MkContainer>
+ </div>
+
+ <!--<XMetrics/>-->
+
+ <MkFolder style="margin: var(--margin)">
+ <template #header><i class="fas fa-info-circle"></i> {{ $ts.info }}</template>
+ <div class="cfcdecdf">
+ <div class="number _panel">
+ <div class="label">Misskey</div>
+ <div class="value _monospace">{{ version }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">Node.js</div>
+ <div class="value _monospace">{{ serverInfo.node }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">PostgreSQL</div>
+ <div class="value _monospace">{{ serverInfo.psql }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">Redis</div>
+ <div class="value _monospace">{{ serverInfo.redis }}</div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Vue</div>
+ <div class="value _monospace">{{ vueVersion }}</div>
+ </div>
+ </div>
+ </MkFolder>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw, version as vueVersion } from 'vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import MkInstanceStats from '@/components/instance-stats.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkNumberDiff from '@/components/number-diff.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkQueueChart from '@/components/queue-chart.vue';
+import { version, url } from '@/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 '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkNumberDiff,
+ FormKeyValueView,
+ MkInstanceStats,
+ MkContainer,
+ MkFolder,
+ MkQueueChart,
+ XMetrics,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.dashboard,
+ icon: 'fas fa-tachometer-alt',
+ bg: 'var(--bg)',
+ },
+ version,
+ vueVersion,
+ url,
+ stats: null,
+ meta: null,
+ serverInfo: null,
+ usersComparedToThePrevDay: null,
+ notesComparedToThePrevDay: null,
+ fetchJobs: () => os.api('admin/queue/deliver-delayed', {}),
+ fetchModLogs: () => os.api('admin/show-moderation-logs', {}),
+ queueStatsConnection: markRaw(os.stream.useChannel('queueStats')),
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+
+ os.api('stats', {}).then(stats => {
+ this.stats = stats;
+
+ os.api('charts/users', { limit: 2, span: 'day' }).then(chart => {
+ this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1];
+ });
+
+ os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+ this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1];
+ });
+ });
+
+ os.api('admin/server-info', {}).then(serverInfo => {
+ this.serverInfo = serverInfo;
+ });
+
+ this.$nextTick(() => {
+ this.queueStatsConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.queueStatsConnection.dispose();
+ },
+
+ methods: {
+ 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>
+
+<style lang="scss" scoped>
+.edbbcaef {
+ .cfcdecdf {
+ display: grid;
+ grid-gap: 8px;
+ grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
+
+ > .number {
+ padding: 12px 16px;
+
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
+
+ > .value {
+ font-weight: bold;
+ font-size: 1.2em;
+
+ > .diff {
+ font-size: 0.8em;
+ }
+ }
+ }
+ }
+
+ > .charts {
+ margin: var(--margin);
+ }
+
+ > .queue {
+ margin: var(--margin);
+ display: flex;
+
+ > .deliver,
+ > .inbox {
+ flex: 1;
+ width: 50%;
+
+ &:not(:first-child) {
+ margin-left: var(--margin);
+ }
+ }
+ }
+
+ &.max-width_740px {
+ > .queue {
+ display: block;
+
+ > .deliver,
+ > .inbox {
+ width: 100%;
+
+ &:not(:first-child) {
+ margin-top: var(--margin);
+ margin-left: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
new file mode 100644
index 0000000000..5852c6a20d
--- /dev/null
+++ b/packages/client/src/pages/admin/proxy-account.vue
@@ -0,0 +1,87 @@
+<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 '@/components/debobigego/key-value-view.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ proxyAccount: null,
+ proxyAccountId: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.proxyAccountId = meta.proxyAccountId;
+ if (this.proxyAccountId) {
+ this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId });
+ }
+ },
+
+ chooseProxyAccount() {
+ os.selectUser().then(user => {
+ this.proxyAccount = user;
+ this.proxyAccountId = user.id;
+ this.save();
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ proxyAccountId: this.proxyAccountId,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue
new file mode 100644
index 0000000000..136fb63bb6
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.chart.vue
@@ -0,0 +1,102 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><slot name="title"></slot></div>
+ <div class="_debobigegoPanel pumxzjhg">
+ <div class="_table status">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
+ <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
+ <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
+ <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+ </div>
+ </div>
+ <div class="">
+ <MkQueueChart :domain="domain" :connection="connection"/>
+ </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>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw, onMounted, onUnmounted, ref } from 'vue';
+import number from '@/filters/number';
+import MkQueueChart from '@/components/queue-chart.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkQueueChart
+ },
+
+ props: {
+ domain: {
+ type: String,
+ required: true,
+ },
+ connection: {
+ required: true,
+ },
+ },
+
+ setup(props) {
+ const activeSincePrevTick = ref(0);
+ const active = ref(0);
+ const waiting = ref(0);
+ const delayed = ref(0);
+ const jobs = ref([]);
+
+ onMounted(() => {
+ os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
+ jobs.value = result;
+ });
+
+ const onStats = (stats) => {
+ activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+ active.value = stats[props.domain].active;
+ waiting.value = stats[props.domain].waiting;
+ delayed.value = stats[props.domain].delayed;
+ };
+
+ props.connection.on('stats', onStats);
+
+ onUnmounted(() => {
+ props.connection.off('stats', onStats);
+ });
+ });
+
+ return {
+ jobs,
+ activeSincePrevTick,
+ active,
+ waiting,
+ delayed,
+ number,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.pumxzjhg {
+ > .status {
+ padding: 16px;
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .jobs {
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ max-height: 180px;
+ overflow: auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
new file mode 100644
index 0000000000..896298840c
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.vue
@@ -0,0 +1,73 @@
+<template>
+<FormBase>
+ <XQueue :connection="connection" domain="inbox">
+ <template #title>In</template>
+ </XQueue>
+ <XQueue :connection="connection" domain="deliver">
+ <template #title>Out</template>
+ </XQueue>
+ <FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XQueue from './queue.chart.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ MkButton,
+ XQueue,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.jobQueue,
+ icon: 'fas fa-clipboard-list',
+ bg: 'var(--bg)',
+ },
+ connection: markRaw(os.stream.useChannel('queueStats')),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ this.$nextTick(() => {
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ clear() {
+ os.dialog({
+ type: 'warning',
+ title: this.$ts.clearQueueConfirmTitle,
+ text: this.$ts.clearQueueConfirmText,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/queue/clear', {});
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
new file mode 100644
index 0000000000..fd0ce97d57
--- /dev/null
+++ b/packages/client/src/pages/admin/relays.vue
@@ -0,0 +1,99 @@
+<template>
+<FormBase class="relaycxt">
+ <FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton>
+
+ <div class="_debobigegoItem" v-for="relay in relays" :key="relay.inbox">
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <div>{{ relay.inbox }}</div>
+ <div>{{ $t(`_relayStatus.${relay.status}`) }}</div>
+ <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ MkButton,
+ MkInput,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.relays,
+ icon: 'fas fa-globe',
+ bg: 'var(--bg)',
+ },
+ relays: [],
+ inbox: '',
+ }
+ },
+
+ created() {
+ this.refresh();
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async addRelay() {
+ const { canceled, result: inbox } = await os.dialog({
+ title: this.$ts.addRelay,
+ input: {
+ placeholder: this.$ts.inboxUrl
+ }
+ });
+ if (canceled) return;
+ os.api('admin/relays/add', {
+ inbox
+ }).then((relay: any) => {
+ this.refresh();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message || e
+ });
+ });
+ },
+
+ remove(inbox: string) {
+ os.api('admin/relays/remove', {
+ inbox
+ }).then(() => {
+ this.refresh();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message || e
+ });
+ });
+ },
+
+ refresh() {
+ os.api('admin/relays/list').then((relays: any) => {
+ this.relays = relays;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
new file mode 100644
index 0000000000..ad53ec4fcf
--- /dev/null
+++ b/packages/client/src/pages/admin/security.vue
@@ -0,0 +1,83 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormLink to="/admin/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="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch>
+
+ <FormSwitch v-model="emailRequiredForSignup">{{ $ts.emailRequiredForSignup }}</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 '@/components/debobigego/link.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/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',
+ bg: 'var(--bg)',
+ },
+ enableHcaptcha: false,
+ enableRecaptcha: false,
+ enableRegistration: false,
+ emailRequiredForSignup: 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;
+ this.emailRequiredForSignup = meta.emailRequiredForSignup;
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ disableRegistration: !this.enableRegistration,
+ emailRequiredForSignup: this.emailRequiredForSignup,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/service-worker.vue b/packages/client/src/pages/admin/service-worker.vue
new file mode 100644
index 0000000000..9e91d6d64f
--- /dev/null
+++ b/packages/client/src/pages/admin/service-worker.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableServiceWorker">
+ {{ $ts.enableServiceworker }}
+ <template #desc>{{ $ts.serviceworkerInfo }}</template>
+ </FormSwitch>
+
+ <template v-if="enableServiceWorker">
+ <FormInput v-model="swPublicKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Public key
+ </FormInput>
+
+ <FormInput v-model="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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'ServiceWorker',
+ icon: 'fas fa-bolt',
+ bg: 'var(--bg)',
+ },
+ enableServiceWorker: false,
+ swPublicKey: null,
+ swPrivateKey: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableServiceWorker = meta.enableServiceWorker;
+ this.swPublicKey = meta.swPublickey;
+ this.swPrivateKey = meta.swPrivateKey;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableServiceWorker: this.enableServiceWorker,
+ swPublicKey: this.swPublicKey,
+ swPrivateKey: this.swPrivateKey,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
new file mode 100644
index 0000000000..66aa3e21db
--- /dev/null
+++ b/packages/client/src/pages/admin/settings.vue
@@ -0,0 +1,151 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormInput v-model="name">
+ <span>{{ $ts.instanceName }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="description">
+ <span>{{ $ts.instanceDescription }}</span>
+ </FormTextarea>
+
+ <FormInput v-model="iconUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.iconUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="bannerUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.bannerUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="backgroundImageUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.backgroundImageUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="tosUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.tosUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="maintainerName">
+ <span>{{ $ts.maintainerName }}</span>
+ </FormInput>
+
+ <FormInput v-model="maintainerEmail" type="email">
+ <template #prefix><i class="fas fa-envelope"></i></template>
+ <span>{{ $ts.maintainerEmail }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="pinnedUsers">
+ <span>{{ $ts.pinnedUsers }}</span>
+ <template #desc>{{ $ts.pinnedUsersDescription }}</template>
+ </FormTextarea>
+
+ <FormInput v-model="maxNoteTextLength" type="number">
+ <template #prefix><i class="fas fa-pencil-alt"></i></template>
+ <span>{{ $ts.maxNoteTextLength }}</span>
+ </FormInput>
+
+ <FormSwitch v-model="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch>
+ <FormSwitch v-model="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch>
+ <FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo>
+
+ <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 '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.general,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ },
+ name: null,
+ description: null,
+ tosUrl: null as string | null,
+ maintainerName: null,
+ maintainerEmail: null,
+ iconUrl: null,
+ bannerUrl: null,
+ backgroundImageUrl: null,
+ maxNoteTextLength: 0,
+ enableLocalTimeline: false,
+ enableGlobalTimeline: false,
+ pinnedUsers: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.name = meta.name;
+ this.description = meta.description;
+ this.tosUrl = meta.tosUrl;
+ this.iconUrl = meta.iconUrl;
+ this.bannerUrl = meta.bannerUrl;
+ this.backgroundImageUrl = meta.backgroundImageUrl;
+ this.maintainerName = meta.maintainerName;
+ this.maintainerEmail = meta.maintainerEmail;
+ this.maxNoteTextLength = meta.maxNoteTextLength;
+ this.enableLocalTimeline = !meta.disableLocalTimeline;
+ this.enableGlobalTimeline = !meta.disableGlobalTimeline;
+ this.pinnedUsers = meta.pinnedUsers.join('\n');
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ name: this.name,
+ description: this.description,
+ tosUrl: this.tosUrl,
+ iconUrl: this.iconUrl,
+ bannerUrl: this.bannerUrl,
+ backgroundImageUrl: this.backgroundImageUrl,
+ maintainerName: this.maintainerName,
+ maintainerEmail: this.maintainerEmail,
+ maxNoteTextLength: this.maxNoteTextLength,
+ disableLocalTimeline: !this.enableLocalTimeline,
+ disableGlobalTimeline: !this.enableGlobalTimeline,
+ pinnedUsers: this.pinnedUsers.split('\n'),
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
new file mode 100644
index 0000000000..f4a2ffa6d2
--- /dev/null
+++ b/packages/client/src/pages/admin/users.vue
@@ -0,0 +1,254 @@
+<template>
+<div class="lknzcolw">
+ <div class="users">
+ <div class="inputs">
+ <MkSelect v-model="sort" style="flex: 1;">
+ <template #label>{{ $ts.sort }}</template>
+ <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+ <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+ </MkSelect>
+ <MkSelect v-model="state" style="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="origin" style="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">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.username }}</template>
+ </MkInput>
+ <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()" :disabled="pagination.params().origin === 'local'">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.host }}</template>
+ </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>
+ </button>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { lookupUser } from '@/scripts/lookup-user';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.users,
+ icon: 'fas fa-users',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-search',
+ text: this.$ts.search,
+ handler: this.searchUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.addUser,
+ handler: this.addUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-search',
+ text: this.$ts.lookup,
+ handler: this.lookupUser
+ }],
+ },
+ sort: '+createdAt',
+ state: 'all',
+ origin: 'local',
+ searchUsername: '',
+ searchHost: '',
+ pagination: {
+ endpoint: 'admin/show-users',
+ limit: 10,
+ params: () => ({
+ sort: this.sort,
+ state: this.state,
+ origin: this.origin,
+ username: this.searchUsername,
+ hostname: this.searchHost,
+ }),
+ offsetMode: true
+ },
+ }
+ },
+
+ watch: {
+ sort() {
+ this.$refs.users.reload();
+ },
+ state() {
+ this.$refs.users.reload();
+ },
+ origin() {
+ this.$refs.users.reload();
+ },
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ lookupUser,
+
+ searchUser() {
+ os.selectUser().then(user => {
+ this.show(user);
+ });
+ },
+
+ async addUser() {
+ const { canceled: canceled1, result: username } = await os.dialog({
+ title: this.$ts.username,
+ input: true
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: password } = await os.dialog({
+ title: this.$ts.password,
+ input: { type: 'password' }
+ });
+ if (canceled2) return;
+
+ os.apiWithDialog('admin/accounts/create', {
+ username: username,
+ password: password,
+ }).then(res => {
+ this.$refs.users.reload();
+ });
+ },
+
+ show(user) {
+ os.pageWindow(`/user-info/${user.id}`);
+ },
+
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lknzcolw {
+ > .users {
+ margin: var(--margin);
+
+ > .inputs {
+ display: flex;
+ margin-bottom: 16px;
+
+ > * {
+ margin-right: 16px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+
+ > .users {
+ margin-top: var(--margin);
+
+ > .user {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+ padding: 16px;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .avatar {
+ width: 60px;
+ height: 60px;
+ }
+
+ > .body {
+ margin-left: 0.3em;
+ padding: 0 8px;
+ flex: 1;
+
+ @media (max-width: 500px) {
+ font-size: 14px;
+ }
+
+ > header {
+ > .name {
+ font-weight: bold;
+ }
+
+ > .acct {
+ margin-left: 8px;
+ opacity: 0.7;
+ }
+
+ > .staff {
+ margin-left: 0.5em;
+ color: var(--badge);
+ }
+
+ > .punished {
+ margin-left: 0.5em;
+ color: #4dabf7;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>