diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-13 12:23:49 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-13 12:23:49 +0900 |
| commit | 2795fe457909c687f668d020ef65d52abc3182fb (patch) | |
| tree | 0a52e4e4d854333496fcc487560c93c3de5d5eb5 /packages/client/src/pages/admin | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.96.0 (diff) | |
| download | misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.gz misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.bz2 misskey-2795fe457909c687f668d020ef65d52abc3182fb.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages/admin')
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> |