170
packages/client/src/pages/admin/abuses.vue
Normal file
170
packages/client/src/pages/admin/abuses.vue
Normal file
@ -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>
|
138
packages/client/src/pages/admin/ads.vue
Normal file
138
packages/client/src/pages/admin/ads.vue
Normal file
@ -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>
|
125
packages/client/src/pages/admin/announcements.vue
Normal file
125
packages/client/src/pages/admin/announcements.vue
Normal file
@ -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>
|
138
packages/client/src/pages/admin/bot-protection.vue
Normal file
138
packages/client/src/pages/admin/bot-protection.vue
Normal file
@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<FormBase>
|
||||
<FormSuspense :p="init">
|
||||
<FormRadios v-model="provider">
|
||||
<template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template>
|
||||
<option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option>
|
||||
<option value="hcaptcha">hCaptcha</option>
|
||||
<option value="recaptcha">reCAPTCHA</option>
|
||||
</FormRadios>
|
||||
|
||||
<template v-if="provider === 'hcaptcha'">
|
||||
<div class="_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>
|
61
packages/client/src/pages/admin/database.vue
Normal file
61
packages/client/src/pages/admin/database.vue
Normal file
@ -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>
|
128
packages/client/src/pages/admin/email-settings.vue
Normal file
128
packages/client/src/pages/admin/email-settings.vue
Normal file
@ -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>
|
120
packages/client/src/pages/admin/emoji-edit-dialog.vue
Normal file
120
packages/client/src/pages/admin/emoji-edit-dialog.vue
Normal file
@ -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>
|
263
packages/client/src/pages/admin/emojis.vue
Normal file
263
packages/client/src/pages/admin/emojis.vue
Normal file
@ -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>
|
129
packages/client/src/pages/admin/file-dialog.vue
Normal file
129
packages/client/src/pages/admin/file-dialog.vue
Normal file
@ -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>
|
93
packages/client/src/pages/admin/files-settings.vue
Normal file
93
packages/client/src/pages/admin/files-settings.vue
Normal file
@ -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>
|
209
packages/client/src/pages/admin/files.vue
Normal file
209
packages/client/src/pages/admin/files.vue
Normal file
@ -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>
|
388
packages/client/src/pages/admin/index.vue
Normal file
388
packages/client/src/pages/admin/index.vue
Normal file
@ -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>
|
72
packages/client/src/pages/admin/instance-block.vue
Normal file
72
packages/client/src/pages/admin/instance-block.vue
Normal file
@ -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>
|
321
packages/client/src/pages/admin/instance.vue
Normal file
321
packages/client/src/pages/admin/instance.vue
Normal file
@ -0,0 +1,321 @@
|
||||
<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 MkUsersDialog from '@/components/users-dialog.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() {
|
||||
os.modal(MkUsersDialog, {
|
||||
title: this.$ts.instanceFollowing,
|
||||
pagination: {
|
||||
endpoint: 'federation/following',
|
||||
limit: 10,
|
||||
params: {
|
||||
host: this.instance.host
|
||||
}
|
||||
},
|
||||
extract: item => item.follower
|
||||
});
|
||||
},
|
||||
|
||||
showFollowers() {
|
||||
os.modal(MkUsersDialog, {
|
||||
title: this.$ts.instanceFollowers,
|
||||
pagination: {
|
||||
endpoint: 'federation/followers',
|
||||
limit: 10,
|
||||
params: {
|
||||
host: this.instance.host
|
||||
}
|
||||
},
|
||||
extract: item => item.followee
|
||||
});
|
||||
},
|
||||
|
||||
showUsers() {
|
||||
os.modal(MkUsersDialog, {
|
||||
title: this.$ts.instanceUsers,
|
||||
pagination: {
|
||||
endpoint: 'federation/users',
|
||||
limit: 10,
|
||||
params: {
|
||||
host: this.instance.host
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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>
|
85
packages/client/src/pages/admin/integrations-discord.vue
Normal file
85
packages/client/src/pages/admin/integrations-discord.vue
Normal file
@ -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>
|
85
packages/client/src/pages/admin/integrations-github.vue
Normal file
85
packages/client/src/pages/admin/integrations-github.vue
Normal file
@ -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>
|
85
packages/client/src/pages/admin/integrations-twitter.vue
Normal file
85
packages/client/src/pages/admin/integrations-twitter.vue
Normal file
@ -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>
|
74
packages/client/src/pages/admin/integrations.vue
Normal file
74
packages/client/src/pages/admin/integrations.vue
Normal file
@ -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>
|
472
packages/client/src/pages/admin/metrics.vue
Normal file
472
packages/client/src/pages/admin/metrics.vue
Normal file
@ -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>
|
155
packages/client/src/pages/admin/object-storage.vue
Normal file
155
packages/client/src/pages/admin/object-storage.vue
Normal file
@ -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>
|
83
packages/client/src/pages/admin/other-settings.vue
Normal file
83
packages/client/src/pages/admin/other-settings.vue
Normal file
@ -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>
|
236
packages/client/src/pages/admin/overview.vue
Normal file
236
packages/client/src/pages/admin/overview.vue
Normal file
@ -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>
|
87
packages/client/src/pages/admin/proxy-account.vue
Normal file
87
packages/client/src/pages/admin/proxy-account.vue
Normal file
@ -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>
|
102
packages/client/src/pages/admin/queue.chart.vue
Normal file
102
packages/client/src/pages/admin/queue.chart.vue
Normal file
@ -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>
|
73
packages/client/src/pages/admin/queue.vue
Normal file
73
packages/client/src/pages/admin/queue.vue
Normal file
@ -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>
|
99
packages/client/src/pages/admin/relays.vue
Normal file
99
packages/client/src/pages/admin/relays.vue
Normal file
@ -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>
|
83
packages/client/src/pages/admin/security.vue
Normal file
83
packages/client/src/pages/admin/security.vue
Normal file
@ -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>
|
85
packages/client/src/pages/admin/service-worker.vue
Normal file
85
packages/client/src/pages/admin/service-worker.vue
Normal file
@ -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>
|
151
packages/client/src/pages/admin/settings.vue
Normal file
151
packages/client/src/pages/admin/settings.vue
Normal file
@ -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>
|
254
packages/client/src/pages/admin/users.vue
Normal file
254
packages/client/src/pages/admin/users.vue
Normal file
@ -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>
|
Reference in New Issue
Block a user