summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages/admin
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-12-27 14:36:33 +0900
commit9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch)
treece5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/pages/admin
parentwip: retention for dashboard (diff)
downloadmisskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz
misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2
misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/pages/admin')
-rw-r--r--packages/frontend/src/pages/admin/_header_.vue292
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue97
-rw-r--r--packages/frontend/src/pages/admin/ads.vue132
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue112
-rw-r--r--packages/frontend/src/pages/admin/bot-protection.vue109
-rw-r--r--packages/frontend/src/pages/admin/database.vue35
-rw-r--r--packages/frontend/src/pages/admin/email-settings.vue126
-rw-r--r--packages/frontend/src/pages/admin/emoji-edit-dialog.vue106
-rw-r--r--packages/frontend/src/pages/admin/emojis.vue398
-rw-r--r--packages/frontend/src/pages/admin/files.vue120
-rw-r--r--packages/frontend/src/pages/admin/index.vue316
-rw-r--r--packages/frontend/src/pages/admin/instance-block.vue51
-rw-r--r--packages/frontend/src/pages/admin/integrations.discord.vue60
-rw-r--r--packages/frontend/src/pages/admin/integrations.github.vue60
-rw-r--r--packages/frontend/src/pages/admin/integrations.twitter.vue60
-rw-r--r--packages/frontend/src/pages/admin/integrations.vue57
-rw-r--r--packages/frontend/src/pages/admin/metrics.vue472
-rw-r--r--packages/frontend/src/pages/admin/object-storage.vue148
-rw-r--r--packages/frontend/src/pages/admin/other-settings.vue44
-rw-r--r--packages/frontend/src/pages/admin/overview.active-users.vue217
-rw-r--r--packages/frontend/src/pages/admin/overview.ap-requests.vue346
-rw-r--r--packages/frontend/src/pages/admin/overview.federation.vue185
-rw-r--r--packages/frontend/src/pages/admin/overview.heatmap.vue15
-rw-r--r--packages/frontend/src/pages/admin/overview.instances.vue50
-rw-r--r--packages/frontend/src/pages/admin/overview.moderators.vue55
-rw-r--r--packages/frontend/src/pages/admin/overview.pie.vue110
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.chart.vue186
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.vue127
-rw-r--r--packages/frontend/src/pages/admin/overview.retention.vue49
-rw-r--r--packages/frontend/src/pages/admin/overview.stats.vue155
-rw-r--r--packages/frontend/src/pages/admin/overview.users.vue57
-rw-r--r--packages/frontend/src/pages/admin/overview.vue190
-rw-r--r--packages/frontend/src/pages/admin/proxy-account.vue62
-rw-r--r--packages/frontend/src/pages/admin/queue.chart.chart.vue186
-rw-r--r--packages/frontend/src/pages/admin/queue.chart.vue149
-rw-r--r--packages/frontend/src/pages/admin/queue.vue56
-rw-r--r--packages/frontend/src/pages/admin/relays.vue103
-rw-r--r--packages/frontend/src/pages/admin/security.vue179
-rw-r--r--packages/frontend/src/pages/admin/settings.vue262
-rw-r--r--packages/frontend/src/pages/admin/users.vue170
40 files changed, 5704 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue
new file mode 100644
index 0000000000..bdb41b2d2c
--- /dev/null
+++ b/packages/frontend/src/pages/admin/_header_.vue
@@ -0,0 +1,292 @@
+<template>
+<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick">
+ <template v-if="metadata">
+ <div class="titleContainer" @click="showTabsPopup">
+ <i v-if="metadata.icon" class="icon" :class="metadata.icon"></i>
+
+ <div class="title">
+ <div class="title">{{ metadata.title }}</div>
+ </div>
+ </div>
+ <div class="tabs">
+ <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
+ <i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+ <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+ </button>
+ <div ref="tabHighlightEl" class="highlight"></div>
+ </div>
+ </template>
+ <div class="buttons right">
+ <template v-if="actions">
+ <template v-for="action in actions">
+ <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+ <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
+ </template>
+ </template>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
+import tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { url } from '@/config';
+import { scrollToTop } from '@/scripts/scroll';
+import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+import { injectPageMetadata } from '@/scripts/page-metadata';
+
+type Tab = {
+ key?: string | null;
+ title: string;
+ icon?: string;
+ iconOnly?: boolean;
+ onClick?: (ev: MouseEvent) => void;
+};
+
+const props = defineProps<{
+ tabs?: Tab[];
+ tab?: string;
+ actions?: {
+ text: string;
+ icon: string;
+ asFullButton?: boolean;
+ handler: (ev: MouseEvent) => void;
+ }[];
+ thin?: boolean;
+}>();
+
+const emit = defineEmits<{
+ (ev: 'update:tab', key: string);
+}>();
+
+const metadata = injectPageMetadata();
+
+const el = ref<HTMLElement>(null);
+const tabRefs = {};
+const tabHighlightEl = $ref<HTMLElement | null>(null);
+const bg = ref(null);
+const height = ref(0);
+const hasTabs = computed(() => {
+ return props.tabs && props.tabs.length > 0;
+});
+
+const showTabsPopup = (ev: MouseEvent) => {
+ if (!hasTabs.value) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ const menu = props.tabs.map(tab => ({
+ text: tab.title,
+ icon: tab.icon,
+ active: tab.key != null && tab.key === props.tab,
+ action: (ev) => {
+ onTabClick(tab, ev);
+ },
+ }));
+ popupMenu(menu, ev.currentTarget ?? ev.target);
+};
+
+const preventDrag = (ev: TouchEvent) => {
+ ev.stopPropagation();
+};
+
+const onClick = () => {
+ scrollToTop(el.value, { behavior: 'smooth' });
+};
+
+function onTabMousedown(tab: Tab, ev: MouseEvent): void {
+ // ユーザビリティの観点からmousedown時にはonClickは呼ばない
+ if (tab.key) {
+ emit('update:tab', tab.key);
+ }
+}
+
+function onTabClick(tab: Tab, ev: MouseEvent): void {
+ if (tab.onClick) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ tab.onClick(ev);
+ }
+ if (tab.key) {
+ emit('update:tab', tab.key);
+ }
+}
+
+const calcBg = () => {
+ const rawBg = metadata?.bg || 'var(--bg)';
+ const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ tinyBg.setAlpha(0.85);
+ bg.value = tinyBg.toRgbString();
+};
+
+onMounted(() => {
+ calcBg();
+ globalEvents.on('themeChanged', calcBg);
+
+ watch(() => [props.tab, props.tabs], () => {
+ nextTick(() => {
+ const tabEl = tabRefs[props.tab];
+ if (tabEl && tabHighlightEl) {
+ // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+ // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+ const parentRect = tabEl.parentElement.getBoundingClientRect();
+ const rect = tabEl.getBoundingClientRect();
+ tabHighlightEl.style.width = rect.width + 'px';
+ tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+ }
+ });
+ }, {
+ immediate: true,
+ });
+});
+
+onUnmounted(() => {
+ globalEvents.off('themeChanged', calcBg);
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkc {
+ --height: 60px;
+ display: flex;
+ width: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+
+ > .buttons {
+ --margin: 8px;
+ display: flex;
+ align-items: center;
+ height: var(--height);
+ margin: 0 var(--margin);
+
+ &.right {
+ margin-left: auto;
+ }
+
+ &:empty {
+ width: var(--height);
+ }
+
+ > .button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: calc(var(--height) - (var(--margin) * 2));
+ width: calc(var(--height) - (var(--margin) * 2));
+ box-sizing: border-box;
+ position: relative;
+ border-radius: 5px;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &.highlighted {
+ color: var(--accent);
+ }
+ }
+
+ > .fullButton {
+ & + .fullButton {
+ margin-left: 12px;
+ }
+ }
+ }
+
+ > .titleContainer {
+ display: flex;
+ align-items: center;
+ max-width: 400px;
+ overflow: auto;
+ white-space: nowrap;
+ text-align: left;
+ font-weight: bold;
+ flex-shrink: 0;
+ margin-left: 24px;
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: bottom;
+ margin: 0 8px;
+ pointer-events: none;
+ }
+
+ > .icon {
+ margin-right: 8px;
+ width: 16px;
+ text-align: center;
+ }
+
+ > .title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.1;
+
+ > .subtitle {
+ opacity: 0.6;
+ font-size: 0.8em;
+ font-weight: normal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.activeTab {
+ text-align: center;
+
+ > .chevron {
+ display: inline-block;
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+ }
+
+ > .tabs {
+ position: relative;
+ margin-left: 16px;
+ font-size: 0.8em;
+ overflow: auto;
+ white-space: nowrap;
+
+ > .tab {
+ display: inline-block;
+ position: relative;
+ padding: 0 10px;
+ height: 100%;
+ font-weight: normal;
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &.active {
+ opacity: 1;
+ }
+
+ > .icon + .title {
+ margin-left: 8px;
+ }
+ }
+
+ > .highlight {
+ position: absolute;
+ bottom: 0;
+ height: 3px;
+ background: var(--accent);
+ border-radius: 999px;
+ transition: all 0.2s ease;
+ pointer-events: none;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
new file mode 100644
index 0000000000..973ec871ab
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -0,0 +1,97 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <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>{{ i18n.ts.state }}</template>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="unresolved">{{ i18n.ts.unresolved }}</option>
+ <option value="resolved">{{ i18n.ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.reporteeOrigin }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.reporterOrigin }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.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">
+ <span>{{ i18n.ts.username }}</span>
+ </MkInput>
+ <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'">
+ <span>{{ i18n.ts.host }}</span>
+ </MkInput>
+ </div>
+ -->
+
+ <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
+ <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
+ </MkPagination>
+ </div>
+ </div>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+
+import XHeader from './_header_.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import XAbuseReport from '@/components/MkAbuseReport.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let reports = $ref<InstanceType<typeof MkPagination>>();
+
+let state = $ref('unresolved');
+let reporterOrigin = $ref('combined');
+let targetUserOrigin = $ref('combined');
+let searchUsername = $ref('');
+let searchHost = $ref('');
+
+const pagination = {
+ endpoint: 'admin/abuse-user-reports' as const,
+ limit: 10,
+ params: computed(() => ({
+ state,
+ reporterOrigin,
+ targetUserOrigin,
+ })),
+};
+
+function resolved(reportId) {
+ reports.removeItem(item => item.id === reportId);
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.abuseReports,
+ icon: 'ti ti-exclamation-circle',
+});
+</script>
+
+<style lang="scss" scoped>
+.lcixvhis {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue
new file mode 100644
index 0000000000..2ec926c65c
--- /dev/null
+++ b/packages/frontend/src/pages/admin/ads.vue
@@ -0,0 +1,132 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="uqshojas">
+ <div v-for="ad in ads" class="_panel _formRoot ad">
+ <MkAd v-if="ad.url" :specify="ad"/>
+ <MkInput v-model="ad.url" type="url" class="_formBlock">
+ <template #label>URL</template>
+ </MkInput>
+ <MkInput v-model="ad.imageUrl" class="_formBlock">
+ <template #label>{{ i18n.ts.imageUrl }}</template>
+ </MkInput>
+ <FormRadios v-model="ad.place" class="_formBlock">
+ <template #label>Form</template>
+ <option value="square">square</option>
+ <option value="horizontal">horizontal</option>
+ <option value="horizontal-big">horizontal-big</option>
+ </FormRadios>
+ <!--
+ <div style="margin: 32px 0;">
+ {{ i18n.ts.priority }}
+ <MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="middle">{{ i18n.ts.middle }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
+ </div>
+ -->
+ <FormSplit>
+ <MkInput v-model="ad.ratio" type="number">
+ <template #label>{{ i18n.ts.ratio }}</template>
+ </MkInput>
+ <MkInput v-model="ad.expiresAt" type="date">
+ <template #label>{{ i18n.ts.expiration }}</template>
+ </MkInput>
+ </FormSplit>
+ <MkTextarea v-model="ad.memo" class="_formBlock">
+ <template #label>{{ i18n.ts.memo }}</template>
+ </MkTextarea>
+ <div class="buttons _formBlock">
+ <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton class="button" inline danger @click="remove(ad)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
+ </div>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormSplit from '@/components/form/split.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let ads: any[] = $ref([]);
+
+os.api('admin/ad/list').then(adsResponse => {
+ ads = adsResponse;
+});
+
+function add() {
+ ads.unshift({
+ id: null,
+ memo: '',
+ place: 'square',
+ priority: 'middle',
+ ratio: 1,
+ url: '',
+ imageUrl: null,
+ expiresAt: null,
+ });
+}
+
+function remove(ad) {
+ os.confirm({
+ type: 'warning',
+ text: i18n.t('removeAreYouSure', { x: ad.url }),
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ ads = ads.filter(x => x !== ad);
+ os.apiWithDialog('admin/ad/delete', {
+ id: ad.id,
+ });
+ });
+}
+
+function 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(),
+ });
+ }
+}
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-plus',
+ text: i18n.ts.add,
+ handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.ads,
+ icon: 'ti ti-ad',
+});
+</script>
+
+<style lang="scss" scoped>
+.uqshojas {
+ > .ad {
+ padding: 32px;
+
+ &:not(:last-child) {
+ margin-bottom: var(--margin);
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue
new file mode 100644
index 0000000000..607ad8aa02
--- /dev/null
+++ b/packages/frontend/src/pages/admin/announcements.vue
@@ -0,0 +1,112 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="ztgjmzrw">
+ <section v-for="announcement in announcements" class="_card _gap announcements">
+ <div class="_content announcement">
+ <MkInput v-model="announcement.title">
+ <template #label>{{ i18n.ts.title }}</template>
+ </MkInput>
+ <MkTextarea v-model="announcement.text">
+ <template #label>{{ i18n.ts.text }}</template>
+ </MkTextarea>
+ <MkInput v-model="announcement.imageUrl">
+ <template #label>{{ i18n.ts.imageUrl }}</template>
+ </MkInput>
+ <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p>
+ <div class="buttons">
+ <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let announcements: any[] = $ref([]);
+
+os.api('admin/announcements/list').then(announcementResponse => {
+ announcements = announcementResponse;
+});
+
+function add() {
+ announcements.unshift({
+ id: null,
+ title: '',
+ text: '',
+ imageUrl: null,
+ });
+}
+
+function remove(announcement) {
+ os.confirm({
+ type: 'warning',
+ text: i18n.t('removeAreYouSure', { x: announcement.title }),
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ announcements = announcements.filter(x => x !== announcement);
+ os.api('admin/announcements/delete', announcement);
+ });
+}
+
+function save(announcement) {
+ if (announcement.id == null) {
+ os.api('admin/announcements/create', announcement).then(() => {
+ os.alert({
+ type: 'success',
+ text: i18n.ts.saved,
+ });
+ }).catch(err => {
+ os.alert({
+ type: 'error',
+ text: err,
+ });
+ });
+ } else {
+ os.api('admin/announcements/update', announcement).then(() => {
+ os.alert({
+ type: 'success',
+ text: i18n.ts.saved,
+ });
+ }).catch(err => {
+ os.alert({
+ type: 'error',
+ text: err,
+ });
+ });
+ }
+}
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-plus',
+ text: i18n.ts.add,
+ handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.announcements,
+ icon: 'ti ti-speakerphone',
+});
+</script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
new file mode 100644
index 0000000000..d03961cf95
--- /dev/null
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -0,0 +1,109 @@
+<template>
+<div>
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormRadios v-model="provider" class="_formBlock">
+ <option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
+ <option value="hcaptcha">hCaptcha</option>
+ <option value="recaptcha">reCAPTCHA</option>
+ <option value="turnstile">Turnstile</option>
+ </FormRadios>
+
+ <template v-if="provider === 'hcaptcha'">
+ <FormInput v-model="hcaptchaSiteKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
+ </FormInput>
+ <FormInput v-model="hcaptchaSecretKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
+ </FormInput>
+ <FormSlot class="_formBlock">
+ <template #label>{{ i18n.ts.preview }}</template>
+ <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
+ </FormSlot>
+ </template>
+ <template v-else-if="provider === 'recaptcha'">
+ <FormInput v-model="recaptchaSiteKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
+ </FormInput>
+ <FormInput v-model="recaptchaSecretKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
+ </FormInput>
+ <FormSlot v-if="recaptchaSiteKey" class="_formBlock">
+ <template #label>{{ i18n.ts.preview }}</template>
+ <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
+ </FormSlot>
+ </template>
+ <template v-else-if="provider === 'turnstile'">
+ <FormInput v-model="turnstileSiteKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>{{ i18n.ts.turnstileSiteKey }}</template>
+ </FormInput>
+ <FormInput v-model="turnstileSecretKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>{{ i18n.ts.turnstileSecretKey }}</template>
+ </FormInput>
+ <FormSlot class="_formBlock">
+ <template #label>{{ i18n.ts.preview }}</template>
+ <MkCaptcha provider="turnstile" :sitekey="turnstileSiteKey || '1x00000000000000000000AA'"/>
+ </FormSlot>
+ </template>
+
+ <FormButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+ </FormSuspense>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormSlot from '@/components/form/slot.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
+
+let provider = $ref(null);
+let hcaptchaSiteKey: string | null = $ref(null);
+let hcaptchaSecretKey: string | null = $ref(null);
+let recaptchaSiteKey: string | null = $ref(null);
+let recaptchaSecretKey: string | null = $ref(null);
+let turnstileSiteKey: string | null = $ref(null);
+let turnstileSecretKey: string | null = $ref(null);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ hcaptchaSiteKey = meta.hcaptchaSiteKey;
+ hcaptchaSecretKey = meta.hcaptchaSecretKey;
+ recaptchaSiteKey = meta.recaptchaSiteKey;
+ recaptchaSecretKey = meta.recaptchaSecretKey;
+ turnstileSiteKey = meta.turnstileSiteKey;
+ turnstileSecretKey = meta.turnstileSecretKey;
+
+ provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : meta.enableTurnstile ? 'turnstile' : null;
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableHcaptcha: provider === 'hcaptcha',
+ hcaptchaSiteKey,
+ hcaptchaSecretKey,
+ enableRecaptcha: provider === 'recaptcha',
+ recaptchaSiteKey,
+ recaptchaSecretKey,
+ enableTurnstile: provider === 'turnstile',
+ turnstileSiteKey,
+ turnstileSecretKey,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue
new file mode 100644
index 0000000000..5a0d3d5e51
--- /dev/null
+++ b/packages/frontend/src/pages/admin/database.vue
@@ -0,0 +1,35 @@
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+ <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
+ <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
+ <template #key>{{ table[0] }}</template>
+ <template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template>
+ </MkKeyValue>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import * as os from '@/os';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.database,
+ icon: 'ti ti-database',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue
new file mode 100644
index 0000000000..6c9dee1704
--- /dev/null
+++ b/packages/frontend/src/pages/admin/email-settings.vue
@@ -0,0 +1,126 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormSwitch v-model="enableEmail" class="_formBlock">
+ <template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template>
+ <template #caption>{{ i18n.ts.emailConfigInfo }}</template>
+ </FormSwitch>
+
+ <template v-if="enableEmail">
+ <FormInput v-model="email" type="email" class="_formBlock">
+ <template #label>{{ i18n.ts.emailAddress }}</template>
+ </FormInput>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.smtpConfig }}</template>
+ <FormSplit :min-width="280">
+ <FormInput v-model="smtpHost" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpHost }}</template>
+ </FormInput>
+ <FormInput v-model="smtpPort" type="number" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpPort }}</template>
+ </FormInput>
+ </FormSplit>
+ <FormSplit :min-width="280">
+ <FormInput v-model="smtpUser" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpUser }}</template>
+ </FormInput>
+ <FormInput v-model="smtpPass" type="password" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpPass }}</template>
+ </FormInput>
+ </FormSplit>
+ <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
+ <FormSwitch v-model="smtpSecure" class="_formBlock">
+ <template #label>{{ i18n.ts.smtpSecure }}</template>
+ <template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
+ </FormSwitch>
+ </FormSection>
+ </template>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormSection from '@/components/form/section.vue';
+import * as os from '@/os';
+import { fetchInstance, instance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let enableEmail: boolean = $ref(false);
+let email: any = $ref(null);
+let smtpSecure: boolean = $ref(false);
+let smtpHost: string = $ref('');
+let smtpPort: number = $ref(0);
+let smtpUser: string = $ref('');
+let smtpPass: string = $ref('');
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ enableEmail = meta.enableEmail;
+ email = meta.email;
+ smtpSecure = meta.smtpSecure;
+ smtpHost = meta.smtpHost;
+ smtpPort = meta.smtpPort;
+ smtpUser = meta.smtpUser;
+ smtpPass = meta.smtpPass;
+}
+
+async function testEmail() {
+ const { canceled, result: destination } = await os.inputText({
+ title: i18n.ts.destination,
+ type: 'email',
+ placeholder: instance.maintainerEmail,
+ });
+ if (canceled) return;
+ os.apiWithDialog('admin/send-email', {
+ to: destination,
+ subject: 'Test email',
+ text: 'Yo',
+ });
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableEmail,
+ email,
+ smtpSecure,
+ smtpHost,
+ smtpPort,
+ smtpUser,
+ smtpPass,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ text: i18n.ts.testEmail,
+ handler: testEmail,
+}, {
+ asFullButton: true,
+ icon: 'ti ti-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.emailServer,
+ icon: 'ti ti-mail',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/emoji-edit-dialog.vue b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue
new file mode 100644
index 0000000000..bd601cb1de
--- /dev/null
+++ b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue
@@ -0,0 +1,106 @@
+<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 v-model="name" class="_formBlock">
+ <template #label>{{ i18n.ts.name }}</template>
+ </MkInput>
+ <MkInput v-model="category" class="_formBlock" :datalist="categories">
+ <template #label>{{ i18n.ts.category }}</template>
+ </MkInput>
+ <MkInput v-model="aliases" class="_formBlock">
+ <template #label>{{ i18n.ts.tags }}</template>
+ <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
+ </MkInput>
+ <MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XModalWindow from '@/components/MkModalWindow.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+import { unique } from '@/scripts/array';
+import { i18n } from '@/i18n';
+import { emojiCategories } from '@/instance';
+
+const props = defineProps<{
+ emoji: any,
+}>();
+
+let dialog = $ref(null);
+let name: string = $ref(props.emoji.name);
+let category: string = $ref(props.emoji.category);
+let aliases: string = $ref(props.emoji.aliases.join(' '));
+let categories: string[] = $ref(emojiCategories);
+
+const emit = defineEmits<{
+ (ev: 'done', v: { deleted?: boolean, updated?: any }): void,
+ (ev: 'closed'): void
+}>();
+
+function ok() {
+ update();
+}
+
+async function update() {
+ await os.apiWithDialog('admin/emoji/update', {
+ id: props.emoji.id,
+ name,
+ category,
+ aliases: aliases.split(' '),
+ });
+
+ emit('done', {
+ updated: {
+ id: props.emoji.id,
+ name,
+ category,
+ aliases: aliases.split(' '),
+ },
+ });
+
+ dialog.close();
+}
+
+async function del() {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.t('removeAreYouSure', { x: name }),
+ });
+ if (canceled) return;
+
+ os.api('admin/emoji/delete', {
+ id: props.emoji.id,
+ }).then(() => {
+ emit('done', {
+ deleted: true,
+ });
+ dialog.close();
+ });
+}
+</script>
+
+<style lang="scss" scoped>
+.yigymqpb {
+ > .img {
+ display: block;
+ height: 64px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/emojis.vue b/packages/frontend/src/pages/admin/emojis.vue
new file mode 100644
index 0000000000..14c8466d73
--- /dev/null
+++ b/packages/frontend/src/pages/admin/emojis.vue
@@ -0,0 +1,398 @@
+<template>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="ogwlenmc">
+ <div v-if="tab === 'local'" class="local">
+ <MkInput v-model="query" :debounce="true" type="search">
+ <template #prefix><i class="ti ti-search"></i></template>
+ <template #label>{{ i18n.ts.search }}</template>
+ </MkInput>
+ <MkSwitch v-model="selectMode" style="margin: 8px 0;">
+ <template #label>Select mode</template>
+ </MkSwitch>
+ <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <MkButton inline @click="selectAll">Select all</MkButton>
+ <MkButton inline @click="setCategoryBulk">Set category</MkButton>
+ <MkButton inline @click="addTagBulk">Add tag</MkButton>
+ <MkButton inline @click="removeTagBulk">Remove tag</MkButton>
+ <MkButton inline @click="setTagBulk">Set tag</MkButton>
+ <MkButton inline danger @click="delBulk">Delete</MkButton>
+ </div>
+ <MkPagination ref="emojisPaginationComponent" :pagination="pagination">
+ <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : 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 v-else-if="tab === 'remote'" class="remote">
+ <FormSplit>
+ <MkInput v-model="queryRemote" :debounce="true" type="search">
+ <template #prefix><i class="ti ti-search"></i></template>
+ <template #label>{{ i18n.ts.search }}</template>
+ </MkInput>
+ <MkInput v-model="host" :debounce="true">
+ <template #label>{{ i18n.ts.host }}</template>
+ </MkInput>
+ </FormSplit>
+ <MkPagination :pagination="remotePagination">
+ <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @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>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import MkTab from '@/components/MkTab.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormSplit from '@/components/form/split.vue';
+import { selectFile, selectFiles } from '@/scripts/select-file';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>();
+
+const tab = ref('local');
+const query = ref(null);
+const queryRemote = ref(null);
+const host = ref(null);
+const selectMode = ref(false);
+const selectedEmojis = ref<string[]>([]);
+
+const pagination = {
+ endpoint: 'admin/emoji/list' as const,
+ limit: 30,
+ params: computed(() => ({
+ query: (query.value && query.value !== '') ? query.value : null,
+ })),
+};
+
+const remotePagination = {
+ endpoint: 'admin/emoji/list-remote' as const,
+ limit: 30,
+ params: computed(() => ({
+ query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null,
+ host: (host.value && host.value !== '') ? host.value : null,
+ })),
+};
+
+const selectAll = () => {
+ if (selectedEmojis.value.length > 0) {
+ selectedEmojis.value = [];
+ } else {
+ selectedEmojis.value = emojisPaginationComponent.value.items.map(item => item.id);
+ }
+};
+
+const toggleSelect = (emoji) => {
+ if (selectedEmojis.value.includes(emoji.id)) {
+ selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id);
+ } else {
+ selectedEmojis.value.push(emoji.id);
+ }
+};
+
+const add = async (ev: MouseEvent) => {
+ const files = await selectFiles(ev.currentTarget ?? ev.target, null);
+
+ const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
+ fileId: file.id,
+ })));
+ promise.then(() => {
+ emojisPaginationComponent.value.reload();
+ });
+ os.promiseDialog(promise);
+};
+
+const edit = (emoji) => {
+ os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
+ emoji: emoji,
+ }, {
+ done: result => {
+ if (result.updated) {
+ emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
+ ...oldEmoji,
+ ...result.updated,
+ }));
+ } else if (result.deleted) {
+ emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
+ }
+ },
+ }, 'closed');
+};
+
+const im = (emoji) => {
+ os.apiWithDialog('admin/emoji/copy', {
+ emojiId: emoji.id,
+ });
+};
+
+const remoteMenu = (emoji, ev: MouseEvent) => {
+ os.popupMenu([{
+ type: 'label',
+ text: ':' + emoji.name + ':',
+ }, {
+ text: i18n.ts.import,
+ icon: 'ti ti-plus',
+ action: () => { im(emoji); },
+ }], ev.currentTarget ?? ev.target);
+};
+
+const menu = (ev: MouseEvent) => {
+ os.popupMenu([{
+ icon: 'ti ti-download',
+ text: i18n.ts.export,
+ action: async () => {
+ os.api('export-custom-emojis', {
+ })
+ .then(() => {
+ os.alert({
+ type: 'info',
+ text: i18n.ts.exportRequested,
+ });
+ }).catch((err) => {
+ os.alert({
+ type: 'error',
+ text: err.message,
+ });
+ });
+ },
+ }, {
+ icon: 'ti ti-upload',
+ text: i18n.ts.import,
+ action: async () => {
+ const file = await selectFile(ev.currentTarget ?? ev.target);
+ os.api('admin/emoji/import-zip', {
+ fileId: file.id,
+ })
+ .then(() => {
+ os.alert({
+ type: 'info',
+ text: i18n.ts.importRequested,
+ });
+ }).catch((err) => {
+ os.alert({
+ type: 'error',
+ text: err.message,
+ });
+ });
+ },
+ }], ev.currentTarget ?? ev.target);
+};
+
+const setCategoryBulk = async () => {
+ const { canceled, result } = await os.inputText({
+ title: 'Category',
+ });
+ if (canceled) return;
+ await os.apiWithDialog('admin/emoji/set-category-bulk', {
+ ids: selectedEmojis.value,
+ category: result,
+ });
+ emojisPaginationComponent.value.reload();
+};
+
+const addTagBulk = async () => {
+ const { canceled, result } = await os.inputText({
+ title: 'Tag',
+ });
+ if (canceled) return;
+ await os.apiWithDialog('admin/emoji/add-aliases-bulk', {
+ ids: selectedEmojis.value,
+ aliases: result.split(' '),
+ });
+ emojisPaginationComponent.value.reload();
+};
+
+const removeTagBulk = async () => {
+ const { canceled, result } = await os.inputText({
+ title: 'Tag',
+ });
+ if (canceled) return;
+ await os.apiWithDialog('admin/emoji/remove-aliases-bulk', {
+ ids: selectedEmojis.value,
+ aliases: result.split(' '),
+ });
+ emojisPaginationComponent.value.reload();
+};
+
+const setTagBulk = async () => {
+ const { canceled, result } = await os.inputText({
+ title: 'Tag',
+ });
+ if (canceled) return;
+ await os.apiWithDialog('admin/emoji/set-aliases-bulk', {
+ ids: selectedEmojis.value,
+ aliases: result.split(' '),
+ });
+ emojisPaginationComponent.value.reload();
+};
+
+const delBulk = async () => {
+ const { canceled } = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteConfirm,
+ });
+ if (canceled) return;
+ await os.apiWithDialog('admin/emoji/delete-bulk', {
+ ids: selectedEmojis.value,
+ });
+ emojisPaginationComponent.value.reload();
+};
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-plus',
+ text: i18n.ts.addEmoji,
+ handler: add,
+}, {
+ icon: 'ti ti-dots',
+ handler: menu,
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'local',
+ title: i18n.ts.local,
+}, {
+ key: 'remote',
+ title: i18n.ts.remote,
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.customEmojis,
+ icon: 'ti ti-mood-happy',
+})));
+</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) 0;
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 11px;
+ text-align: left;
+ border: solid 1px var(--panel);
+
+ &:hover {
+ border-color: var(--inputBorderHover);
+ }
+
+ &.selected {
+ border-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) 0;
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .img {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ font-size: 90%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue
new file mode 100644
index 0000000000..8ad6bd4fc0
--- /dev/null
+++ b/packages/frontend/src/pages/admin/files.vue
@@ -0,0 +1,120 @@
+<template>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :actions="headerActions"/></template>
+ <MkSpacer :content-max="900">
+ <div class="xrmjdkdw">
+ <div>
+ <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
+ <MkSelect v-model="origin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.instance }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
+ <template #label>{{ i18n.ts.host }}</template>
+ </MkInput>
+ </div>
+ <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;">
+ <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>User ID</template>
+ </MkInput>
+ <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>MIME type</template>
+ </MkInput>
+ </div>
+ <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/>
+ </div>
+ </div>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let origin = $ref('local');
+let type = $ref(null);
+let searchHost = $ref('');
+let userId = $ref('');
+let viewMode = $ref('grid');
+const pagination = {
+ endpoint: 'admin/drive/files' as const,
+ limit: 10,
+ params: computed(() => ({
+ type: (type && type !== '') ? type : null,
+ userId: (userId && userId !== '') ? userId : null,
+ origin: origin,
+ hostname: (searchHost && searchHost !== '') ? searchHost : null,
+ })),
+};
+
+function clear() {
+ os.confirm({
+ type: 'warning',
+ text: i18n.ts.clearCachedFilesConfirm,
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/drive/clean-remote-files', {});
+ });
+}
+
+function show(file) {
+ os.pageWindow(`/admin/file/${file.id}`);
+}
+
+async function find() {
+ const { canceled, result: q } = await os.inputText({
+ title: i18n.ts.fileIdOrUrl,
+ allowEmpty: false,
+ });
+ if (canceled) return;
+
+ os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
+ show(file);
+ }).catch(err => {
+ if (err.code === 'NO_SUCH_FILE') {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.notFound,
+ });
+ }
+ });
+}
+
+const headerActions = $computed(() => [{
+ text: i18n.ts.lookup,
+ icon: 'ti ti-search',
+ handler: find,
+}, {
+ text: i18n.ts.clearCachedFiles,
+ icon: 'ti ti-trash',
+ handler: clear,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.files,
+ icon: 'ti ti-cloud',
+})));
+</script>
+
+<style lang="scss" scoped>
+.xrmjdkdw {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
new file mode 100644
index 0000000000..6c07a87eeb
--- /dev/null
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -0,0 +1,316 @@
+<template>
+<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
+ <div v-if="!narrow || currentPage?.route.name == null" class="nav">
+ <MkSpacer :content-max="700" :margin-min="16">
+ <div class="lxpfedzu">
+ <div class="banner">
+ <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
+ </div>
+
+ <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
+ <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
+
+ <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
+ </div>
+ </MkSpacer>
+ </div>
+ <div v-if="!(narrow && currentPage?.route.name == null)" class="main">
+ <RouterView/>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkSuperMenu from '@/components/MkSuperMenu.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import { scroll } from '@/scripts/scroll';
+import { instance } from '@/instance';
+import * as os from '@/os';
+import { lookupUser } from '@/scripts/lookup-user';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
+
+const isEmpty = (x: string | null) => x == null || x === '';
+
+const router = useRouter();
+
+const indexInfo = {
+ title: i18n.ts.controlPanel,
+ icon: 'ti ti-settings',
+ hideHeader: true,
+};
+
+provide('shouldOmitHeaderTitle', false);
+
+let INFO = $ref(indexInfo);
+let childInfo = $ref(null);
+let narrow = $ref(false);
+let view = $ref(null);
+let el = $ref(null);
+let pageProps = $ref({});
+let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail);
+let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile;
+let noEmailServer = !instance.enableEmail;
+let thereIsUnresolvedAbuseReport = $ref(false);
+let currentPage = $computed(() => router.currentRef.value.child);
+
+os.api('admin/abuse-user-reports', {
+ state: 'unresolved',
+ limit: 1,
+}).then(reports => {
+ if (reports.length > 0) thereIsUnresolvedAbuseReport = true;
+});
+
+const NARROW_THRESHOLD = 600;
+const ro = new ResizeObserver((entries, observer) => {
+ if (entries.length === 0) return;
+ narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
+});
+
+const menuDef = $computed(() => [{
+ title: i18n.ts.quickAction,
+ items: [{
+ type: 'button',
+ icon: 'ti ti-search',
+ text: i18n.ts.lookup,
+ action: lookup,
+ }, ...(instance.disableRegistration ? [{
+ type: 'button',
+ icon: 'ti ti-user',
+ text: i18n.ts.invite,
+ action: invite,
+ }] : [])],
+}, {
+ title: i18n.ts.administration,
+ items: [{
+ icon: 'ti ti-dashboard',
+ text: i18n.ts.dashboard,
+ to: '/admin/overview',
+ active: currentPage?.route.name === 'overview',
+ }, {
+ icon: 'ti ti-users',
+ text: i18n.ts.users,
+ to: '/admin/users',
+ active: currentPage?.route.name === 'users',
+ }, {
+ icon: 'ti ti-mood-happy',
+ text: i18n.ts.customEmojis,
+ to: '/admin/emojis',
+ active: currentPage?.route.name === 'emojis',
+ }, {
+ icon: 'ti ti-whirl',
+ text: i18n.ts.federation,
+ to: '/about#federation',
+ active: currentPage?.route.name === 'federation',
+ }, {
+ icon: 'ti ti-clock-play',
+ text: i18n.ts.jobQueue,
+ to: '/admin/queue',
+ active: currentPage?.route.name === 'queue',
+ }, {
+ icon: 'ti ti-cloud',
+ text: i18n.ts.files,
+ to: '/admin/files',
+ active: currentPage?.route.name === 'files',
+ }, {
+ icon: 'ti ti-speakerphone',
+ text: i18n.ts.announcements,
+ to: '/admin/announcements',
+ active: currentPage?.route.name === 'announcements',
+ }, {
+ icon: 'ti ti-ad',
+ text: i18n.ts.ads,
+ to: '/admin/ads',
+ active: currentPage?.route.name === 'ads',
+ }, {
+ icon: 'ti ti-exclamation-circle',
+ text: i18n.ts.abuseReports,
+ to: '/admin/abuses',
+ active: currentPage?.route.name === 'abuses',
+ }],
+}, {
+ title: i18n.ts.settings,
+ items: [{
+ icon: 'ti ti-settings',
+ text: i18n.ts.general,
+ to: '/admin/settings',
+ active: currentPage?.route.name === 'settings',
+ }, {
+ icon: 'ti ti-mail',
+ text: i18n.ts.emailServer,
+ to: '/admin/email-settings',
+ active: currentPage?.route.name === 'email-settings',
+ }, {
+ icon: 'ti ti-cloud',
+ text: i18n.ts.objectStorage,
+ to: '/admin/object-storage',
+ active: currentPage?.route.name === 'object-storage',
+ }, {
+ icon: 'ti ti-lock',
+ text: i18n.ts.security,
+ to: '/admin/security',
+ active: currentPage?.route.name === 'security',
+ }, {
+ icon: 'ti ti-planet',
+ text: i18n.ts.relays,
+ to: '/admin/relays',
+ active: currentPage?.route.name === 'relays',
+ }, {
+ icon: 'ti ti-share',
+ text: i18n.ts.integration,
+ to: '/admin/integrations',
+ active: currentPage?.route.name === 'integrations',
+ }, {
+ icon: 'ti ti-ban',
+ text: i18n.ts.instanceBlocking,
+ to: '/admin/instance-block',
+ active: currentPage?.route.name === 'instance-block',
+ }, {
+ icon: 'ti ti-ghost',
+ text: i18n.ts.proxyAccount,
+ to: '/admin/proxy-account',
+ active: currentPage?.route.name === 'proxy-account',
+ }, {
+ icon: 'ti ti-adjustments',
+ text: i18n.ts.other,
+ to: '/admin/other-settings',
+ active: currentPage?.route.name === 'other-settings',
+ }],
+}, {
+ title: i18n.ts.info,
+ items: [{
+ icon: 'ti ti-database',
+ text: i18n.ts.database,
+ to: '/admin/database',
+ active: currentPage?.route.name === 'database',
+ }],
+}]);
+
+watch(narrow, () => {
+ if (currentPage?.route.name == null && !narrow) {
+ router.push('/admin/overview');
+ }
+});
+
+onMounted(() => {
+ ro.observe(el);
+
+ narrow = el.offsetWidth < NARROW_THRESHOLD;
+ if (currentPage?.route.name == null && !narrow) {
+ router.push('/admin/overview');
+ }
+});
+
+onUnmounted(() => {
+ ro.disconnect();
+});
+
+provideMetadataReceiver((info) => {
+ if (info == null) {
+ childInfo = null;
+ } else {
+ childInfo = info;
+ }
+});
+
+const invite = () => {
+ os.api('admin/invite').then(x => {
+ os.alert({
+ type: 'info',
+ text: x.code,
+ });
+ }).catch(err => {
+ os.alert({
+ type: 'error',
+ text: err,
+ });
+ });
+};
+
+const lookup = (ev) => {
+ os.popupMenu([{
+ text: i18n.ts.user,
+ icon: 'ti ti-user',
+ action: () => {
+ lookupUser();
+ },
+ }, {
+ text: i18n.ts.note,
+ icon: 'ti ti-pencil',
+ action: () => {
+ alert('TODO');
+ },
+ }, {
+ text: i18n.ts.file,
+ icon: 'ti ti-cloud',
+ action: () => {
+ alert('TODO');
+ },
+ }, {
+ text: i18n.ts.instance,
+ icon: 'ti ti-planet',
+ action: () => {
+ alert('TODO');
+ },
+ }], ev.currentTarget ?? ev.target);
+};
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+
+defineExpose({
+ header: {
+ title: i18n.ts.controlPanel,
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hiyeyicy {
+ &.wide {
+ display: flex;
+ margin: 0 auto;
+ height: 100%;
+
+ > .nav {
+ width: 32%;
+ max-width: 280px;
+ box-sizing: border-box;
+ border-right: solid 0.5px var(--divider);
+ overflow: auto;
+ height: 100%;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+
+ > .nav {
+ .lxpfedzu {
+ > .info {
+ margin: 16px 0;
+ }
+
+ > .banner {
+ margin: 16px;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 42px;
+ border-radius: 8px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue
new file mode 100644
index 0000000000..1bdd174de4
--- /dev/null
+++ b/packages/frontend/src/pages/admin/instance-block.vue
@@ -0,0 +1,51 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <FormTextarea v-model="blockedHosts" class="_formBlock">
+ <span>{{ i18n.ts.blockedInstances }}</span>
+ <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
+ </FormTextarea>
+
+ <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let blockedHosts: string = $ref('');
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ blockedHosts = meta.blockedHosts.join('\n');
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ blockedHosts: blockedHosts.split('\n') || [],
+ }).then(() => {
+ fetchInstance();
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.instanceBlocking,
+ icon: 'ti ti-ban',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.discord.vue b/packages/frontend/src/pages/admin/integrations.discord.vue
new file mode 100644
index 0000000000..0a69c44c93
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.discord.vue
@@ -0,0 +1,60 @@
+<template>
+<FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormSwitch v-model="enableDiscordIntegration" class="_formBlock">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </FormSwitch>
+
+ <template v-if="enableDiscordIntegration">
+ <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/dc/cb` }}</FormInfo>
+
+ <FormInput v-model="discordClientId" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Client ID</template>
+ </FormInput>
+
+ <FormInput v-model="discordClientSecret" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Client Secret</template>
+ </FormInput>
+ </template>
+
+ <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+</FormSuspense>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+let uri: string = $ref('');
+let enableDiscordIntegration: boolean = $ref(false);
+let discordClientId: string | null = $ref(null);
+let discordClientSecret: string | null = $ref(null);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ uri = meta.uri;
+ enableDiscordIntegration = meta.enableDiscordIntegration;
+ discordClientId = meta.discordClientId;
+ discordClientSecret = meta.discordClientSecret;
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableDiscordIntegration,
+ discordClientId,
+ discordClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.github.vue b/packages/frontend/src/pages/admin/integrations.github.vue
new file mode 100644
index 0000000000..66419d5891
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.github.vue
@@ -0,0 +1,60 @@
+<template>
+<FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormSwitch v-model="enableGithubIntegration" class="_formBlock">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </FormSwitch>
+
+ <template v-if="enableGithubIntegration">
+ <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/gh/cb` }}</FormInfo>
+
+ <FormInput v-model="githubClientId" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Client ID</template>
+ </FormInput>
+
+ <FormInput v-model="githubClientSecret" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Client Secret</template>
+ </FormInput>
+ </template>
+
+ <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+</FormSuspense>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+let uri: string = $ref('');
+let enableGithubIntegration: boolean = $ref(false);
+let githubClientId: string | null = $ref(null);
+let githubClientSecret: string | null = $ref(null);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ uri = meta.uri;
+ enableGithubIntegration = meta.enableGithubIntegration;
+ githubClientId = meta.githubClientId;
+ githubClientSecret = meta.githubClientSecret;
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableGithubIntegration,
+ githubClientId,
+ githubClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.twitter.vue b/packages/frontend/src/pages/admin/integrations.twitter.vue
new file mode 100644
index 0000000000..1e8d882b9c
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.twitter.vue
@@ -0,0 +1,60 @@
+<template>
+<FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormSwitch v-model="enableTwitterIntegration" class="_formBlock">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </FormSwitch>
+
+ <template v-if="enableTwitterIntegration">
+ <FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/tw/cb` }}</FormInfo>
+
+ <FormInput v-model="twitterConsumerKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Consumer Key</template>
+ </FormInput>
+
+ <FormInput v-model="twitterConsumerSecret" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Consumer Secret</template>
+ </FormInput>
+ </template>
+
+ <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+</FormSuspense>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+
+let uri: string = $ref('');
+let enableTwitterIntegration: boolean = $ref(false);
+let twitterConsumerKey: string | null = $ref(null);
+let twitterConsumerSecret: string | null = $ref(null);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ uri = meta.uri;
+ enableTwitterIntegration = meta.enableTwitterIntegration;
+ twitterConsumerKey = meta.twitterConsumerKey;
+ twitterConsumerSecret = meta.twitterConsumerSecret;
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableTwitterIntegration,
+ twitterConsumerKey,
+ twitterConsumerSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+</script>
diff --git a/packages/frontend/src/pages/admin/integrations.vue b/packages/frontend/src/pages/admin/integrations.vue
new file mode 100644
index 0000000000..9cc35baefd
--- /dev/null
+++ b/packages/frontend/src/pages/admin/integrations.vue
@@ -0,0 +1,57 @@
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <FormFolder class="_formBlock">
+ <template #icon><i class="ti ti-brand-twitter"></i></template>
+ <template #label>Twitter</template>
+ <template #suffix>{{ enableTwitterIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+ <XTwitter/>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #icon><i class="ti ti-brand-github"></i></template>
+ <template #label>GitHub</template>
+ <template #suffix>{{ enableGithubIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+ <XGithub/>
+ </FormFolder>
+ <FormFolder class="_formBlock">
+ <template #icon><i class="ti ti-brand-discord"></i></template>
+ <template #label>Discord</template>
+ <template #suffix>{{ enableDiscordIntegration ? i18n.ts.enabled : i18n.ts.disabled }}</template>
+ <XDiscord/>
+ </FormFolder>
+ </FormSuspense>
+</MkSpacer></MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XTwitter from './integrations.twitter.vue';
+import XGithub from './integrations.github.vue';
+import XDiscord from './integrations.discord.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormFolder from '@/components/form/folder.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let enableTwitterIntegration: boolean = $ref(false);
+let enableGithubIntegration: boolean = $ref(false);
+let enableDiscordIntegration: boolean = $ref(false);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ enableTwitterIntegration = meta.enableTwitterIntegration;
+ enableGithubIntegration = meta.enableGithubIntegration;
+ enableDiscordIntegration = meta.enableDiscordIntegration;
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.integration,
+ icon: 'ti ti-share',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue
new file mode 100644
index 0000000000..db8e448639
--- /dev/null
+++ b/packages/frontend/src/pages/admin/metrics.vue
@@ -0,0 +1,472 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="cpumem"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
+ <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="disk"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
+ <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="net"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+} from 'chart.js';
+import MkButton from '@/components/MkButton.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkwFederation from '../../widgets/federation.vue';
+import { version, url } from '@/config';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+
+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';
+import { stream } from '@/stream';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSelect,
+ MkInput,
+ MkContainer,
+ MkFolder,
+ MkwFederation,
+ },
+
+ data() {
+ return {
+ version,
+ url,
+ stats: null,
+ serverInfo: null,
+ connection: null,
+ queueConnection: markRaw(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(stream.useChannel('serverStats'));
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 150,
+ });
+
+ this.$nextTick(() => {
+ this.queueConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200,
+ });
+ });
+ });
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ this.connection.dispose();
+ }
+ this.queueConnection.dispose();
+ },
+
+ methods: {
+ cpumem(el) {
+ if (this.chartCpuMem != null) return;
+ this.chartCpuMem = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'CPU',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#86b300',
+ backgroundColor: alpha('#86b300', 0.1),
+ data: [],
+ }, {
+ label: 'MEM (active)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ backgroundColor: alpha('#935dbf', 0.02),
+ data: [],
+ }, {
+ label: 'MEM (used)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ borderDash: [5, 5],
+ fill: false,
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0,
+ },
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ },
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ },
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ max: 100,
+ },
+ },
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ },
+ },
+ }));
+ },
+
+ net(el) {
+ if (this.chartNet != null) return;
+ this.chartNet = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'In',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: [],
+ }, {
+ label: 'Out',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0,
+ },
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ },
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ },
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ },
+ },
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ },
+ },
+ }));
+ },
+
+ disk(el) {
+ if (this.chartDisk != null) return;
+ this.chartDisk = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Read',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: [],
+ }, {
+ label: 'Write',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0,
+ },
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ },
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ },
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ },
+ },
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ },
+ },
+ }));
+ },
+
+ fetchJobs() {
+ os.api('admin/queue/deliver-delayed', {}).then(jobs => {
+ this.jobs = jobs;
+ });
+ },
+
+ onStats(stats) {
+ if (this.paused) return;
+
+ const cpu = (stats.cpu * 100).toFixed(0);
+ const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+ const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+ this.memUsage = stats.mem.active;
+
+ this.chartCpuMem.data.labels.push('');
+ this.chartCpuMem.data.datasets[0].data.push(cpu);
+ this.chartCpuMem.data.datasets[1].data.push(memActive);
+ this.chartCpuMem.data.datasets[2].data.push(memUsed);
+ this.chartNet.data.labels.push('');
+ this.chartNet.data.datasets[0].data.push(stats.net.rx);
+ this.chartNet.data.datasets[1].data.push(stats.net.tx);
+ this.chartDisk.data.labels.push('');
+ this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+ this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+ if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+ this.chartCpuMem.data.labels.shift();
+ this.chartCpuMem.data.datasets[0].data.shift();
+ this.chartCpuMem.data.datasets[1].data.shift();
+ this.chartCpuMem.data.datasets[2].data.shift();
+ this.chartNet.data.labels.shift();
+ this.chartNet.data.datasets[0].data.shift();
+ this.chartNet.data.datasets[1].data.shift();
+ this.chartDisk.data.labels.shift();
+ this.chartDisk.data.datasets[0].data.shift();
+ this.chartDisk.data.datasets[1].data.shift();
+ }
+ this.chartCpuMem.update();
+ this.chartNet.update();
+ this.chartDisk.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of [...statsLog].reverse()) {
+ this.onStats(stats);
+ }
+ },
+
+ bytes,
+
+ number,
+
+ pause() {
+ this.paused = true;
+ },
+
+ resume() {
+ this.paused = false;
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.xhexznfu {
+ > div:nth-child(2) {
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue
new file mode 100644
index 0000000000..f2ab30eaa5
--- /dev/null
+++ b/packages/frontend/src/pages/admin/object-storage.vue
@@ -0,0 +1,148 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch>
+
+ <template v-if="useObjectStorage">
+ <FormInput v-model="objectStorageBaseUrl" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
+ <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageBucket" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageBucket }}</template>
+ <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStoragePrefix" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStoragePrefix }}</template>
+ <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageEndpoint" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
+ <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageRegion" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageRegion }}</template>
+ <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
+ </FormInput>
+
+ <FormSplit :min-width="280">
+ <FormInput v-model="objectStorageAccessKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Access key</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageSecretKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Secret key</template>
+ </FormInput>
+ </FormSplit>
+
+ <FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
+ <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
+ <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
+ <template #label>s3ForcePathStyle</template>
+ </FormSwitch>
+ </template>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormSection from '@/components/form/section.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let useObjectStorage: boolean = $ref(false);
+let objectStorageBaseUrl: string | null = $ref(null);
+let objectStorageBucket: string | null = $ref(null);
+let objectStoragePrefix: string | null = $ref(null);
+let objectStorageEndpoint: string | null = $ref(null);
+let objectStorageRegion: string | null = $ref(null);
+let objectStoragePort: number | null = $ref(null);
+let objectStorageAccessKey: string | null = $ref(null);
+let objectStorageSecretKey: string | null = $ref(null);
+let objectStorageUseSSL: boolean = $ref(false);
+let objectStorageUseProxy: boolean = $ref(false);
+let objectStorageSetPublicRead: boolean = $ref(false);
+let objectStorageS3ForcePathStyle: boolean = $ref(true);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ useObjectStorage = meta.useObjectStorage;
+ objectStorageBaseUrl = meta.objectStorageBaseUrl;
+ objectStorageBucket = meta.objectStorageBucket;
+ objectStoragePrefix = meta.objectStoragePrefix;
+ objectStorageEndpoint = meta.objectStorageEndpoint;
+ objectStorageRegion = meta.objectStorageRegion;
+ objectStoragePort = meta.objectStoragePort;
+ objectStorageAccessKey = meta.objectStorageAccessKey;
+ objectStorageSecretKey = meta.objectStorageSecretKey;
+ objectStorageUseSSL = meta.objectStorageUseSSL;
+ objectStorageUseProxy = meta.objectStorageUseProxy;
+ objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
+ objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ useObjectStorage,
+ objectStorageBaseUrl,
+ objectStorageBucket,
+ objectStoragePrefix,
+ objectStorageEndpoint,
+ objectStorageRegion,
+ objectStoragePort,
+ objectStorageAccessKey,
+ objectStorageSecretKey,
+ objectStorageUseSSL,
+ objectStorageUseProxy,
+ objectStorageSetPublicRead,
+ objectStorageS3ForcePathStyle,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.objectStorage,
+ icon: 'ti ti-cloud',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue
new file mode 100644
index 0000000000..62dff6ce7f
--- /dev/null
+++ b/packages/frontend/src/pages/admin/other-settings.vue
@@ -0,0 +1,44 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ none
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+async function init() {
+ await os.api('admin/meta');
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta').then(() => {
+ fetchInstance();
+ });
+}
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.other,
+ icon: 'ti ti-adjustments',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue
new file mode 100644
index 0000000000..c3ce5ac901
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.active-users.vue
@@ -0,0 +1,217 @@
+<template>
+<div>
+ <MkLoading v-if="fetching"/>
+ <div v-show="!fetching" :class="$style.root" class="_panel">
+ <canvas ref="chartEl"></canvas>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import * as os from '@/os';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import gradient from 'chartjs-plugin-gradient';
+import { chartVLine } from '@/scripts/chart-vline';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ gradient,
+);
+
+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})`;
+};
+
+const chartEl = $ref<HTMLCanvasElement>(null);
+const now = new Date();
+let chartInstance: Chart = null;
+const chartLimit = 7;
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+async function renderChart() {
+ if (chartInstance) {
+ chartInstance.destroy();
+ }
+
+ const getDate = (ago: number) => {
+ const y = now.getFullYear();
+ const m = now.getMonth();
+ const d = now.getDate();
+
+ return new Date(y, m, d - ago);
+ };
+
+ const format = (arr) => {
+ return arr.map((v, i) => ({
+ x: getDate(i).getTime(),
+ y: v,
+ }));
+ };
+
+ const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
+
+ const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+ const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ const colorRead = '#3498db';
+ const colorWrite = '#2ecc71';
+
+ const max = Math.max(...raw.read);
+
+ chartInstance = new Chart(chartEl, {
+ type: 'bar',
+ data: {
+ datasets: [{
+ parsing: false,
+ label: 'Read',
+ data: format(raw.read).slice().reverse(),
+ pointRadius: 0,
+ borderWidth: 0,
+ borderJoinStyle: 'round',
+ borderRadius: 4,
+ backgroundColor: colorRead,
+ barPercentage: 0.7,
+ categoryPercentage: 0.5,
+ fill: true,
+ }, {
+ parsing: false,
+ label: 'Write',
+ data: format(raw.write).slice().reverse(),
+ pointRadius: 0,
+ borderWidth: 0,
+ borderJoinStyle: 'round',
+ borderRadius: 4,
+ backgroundColor: colorWrite,
+ barPercentage: 0.7,
+ categoryPercentage: 0.5,
+ fill: true,
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 8,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ offset: true,
+ time: {
+ stepSize: 1,
+ unit: 'day',
+ },
+ grid: {
+ display: false,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: true,
+ maxRotation: 0,
+ autoSkipPadding: 8,
+ },
+ adapters: {
+ date: {
+ locale: enUS,
+ },
+ },
+ },
+ y: {
+ position: 'left',
+ suggestedMax: 10,
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: true,
+ //mirror: true,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index',
+ },
+ animation: false,
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ gradient,
+ },
+ },
+ plugins: [chartVLine(vLineColor)],
+ });
+
+ fetching = false;
+}
+
+onMounted(async () => {
+ renderChart();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 20px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue
new file mode 100644
index 0000000000..024ffdc245
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue
@@ -0,0 +1,346 @@
+<template>
+<div>
+ <MkLoading v-if="fetching"/>
+ <div v-show="!fetching" :class="$style.root">
+ <div class="charts _panel">
+ <div class="chart">
+ <canvas ref="chartEl2"></canvas>
+ </div>
+ <div class="chart">
+ <canvas ref="chartEl"></canvas>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import gradient from 'chartjs-plugin-gradient';
+import { enUS } from 'date-fns/locale';
+import tinycolor from 'tinycolor2';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import MkNumberDiff from '@/components/MkNumberDiff.vue';
+import { i18n } from '@/i18n';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+import { defaultStore } from '@/store';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ gradient,
+);
+
+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})`;
+};
+
+const chartLimit = 50;
+const chartEl = $ref<HTMLCanvasElement>();
+const chartEl2 = $ref<HTMLCanvasElement>();
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+const { handler: externalTooltipHandler2 } = useChartTooltip();
+
+onMounted(async () => {
+ const now = new Date();
+
+ const getDate = (ago: number) => {
+ const y = now.getFullYear();
+ const m = now.getMonth();
+ const d = now.getDate();
+
+ return new Date(y, m, d - ago);
+ };
+
+ const format = (arr) => {
+ return arr.map((v, i) => ({
+ x: getDate(i).getTime(),
+ y: v,
+ }));
+ };
+
+ const formatMinus = (arr) => {
+ return arr.map((v, i) => ({
+ x: getDate(i).getTime(),
+ y: -v,
+ }));
+ };
+
+ const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' });
+
+ const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+ const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+ const succColor = '#87e000';
+ const failColor = '#ff4400';
+
+ const succMax = Math.max(...raw.deliverSucceeded);
+ const failMax = Math.max(...raw.deliverFailed);
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ new Chart(chartEl, {
+ type: 'line',
+ data: {
+ datasets: [{
+ stack: 'a',
+ parsing: false,
+ label: 'Out: Succ',
+ data: format(raw.deliverSucceeded).slice().reverse(),
+ tension: 0.3,
+ pointRadius: 0,
+ borderWidth: 2,
+ borderColor: succColor,
+ borderJoinStyle: 'round',
+ borderRadius: 4,
+ backgroundColor: alpha(succColor, 0.35),
+ fill: true,
+ clip: 8,
+ }, {
+ stack: 'a',
+ parsing: false,
+ label: 'Out: Fail',
+ data: formatMinus(raw.deliverFailed).slice().reverse(),
+ tension: 0.3,
+ pointRadius: 0,
+ borderWidth: 2,
+ borderColor: failColor,
+ borderJoinStyle: 'round',
+ borderRadius: 4,
+ backgroundColor: alpha(failColor, 0.35),
+ fill: true,
+ clip: 8,
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 8,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ stacked: true,
+ offset: false,
+ time: {
+ stepSize: 1,
+ unit: 'day',
+ },
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: true,
+ maxRotation: 0,
+ autoSkipPadding: 16,
+ },
+ adapters: {
+ date: {
+ locale: enUS,
+ },
+ },
+ min: getDate(chartLimit).getTime(),
+ },
+ y: {
+ stacked: true,
+ position: 'left',
+ suggestedMax: 10,
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: true,
+ //mirror: true,
+ callback: (value, index, values) => value < 0 ? -value : value,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index',
+ },
+ elements: {
+ point: {
+ hoverRadius: 5,
+ hoverBorderWidth: 2,
+ },
+ },
+ animation: false,
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ gradient,
+ },
+ },
+ plugins: [chartVLine(vLineColor)],
+ });
+
+ new Chart(chartEl2, {
+ type: 'bar',
+ data: {
+ datasets: [{
+ parsing: false,
+ label: 'In',
+ data: format(raw.inboxReceived).slice().reverse(),
+ tension: 0.3,
+ pointRadius: 0,
+ borderWidth: 0,
+ borderJoinStyle: 'round',
+ borderRadius: 4,
+ backgroundColor: '#0cc2d6',
+ barPercentage: 0.8,
+ categoryPercentage: 0.9,
+ fill: true,
+ clip: 8,
+ }],
+ },
+ options: {
+ aspectRatio: 5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 8,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ offset: false,
+ time: {
+ stepSize: 1,
+ unit: 'day',
+ },
+ grid: {
+ display: false,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: false,
+ maxRotation: 0,
+ autoSkipPadding: 16,
+ },
+ adapters: {
+ date: {
+ locale: enUS,
+ },
+ },
+ min: getDate(chartLimit).getTime(),
+ },
+ y: {
+ position: 'left',
+ suggestedMax: 10,
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ mode: 'index',
+ },
+ elements: {
+ point: {
+ hoverRadius: 5,
+ hoverBorderWidth: 2,
+ },
+ },
+ animation: false,
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler2,
+ },
+ gradient,
+ },
+ },
+ plugins: [chartVLine(vLineColor)],
+ });
+
+ fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ &:global {
+ > .charts {
+ > .chart {
+ padding: 16px;
+
+ &:first-child {
+ border-bottom: solid 0.5px var(--divider);
+ }
+ }
+ }
+ }
+}
+</style>
+
diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue
new file mode 100644
index 0000000000..71f5a054b4
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.federation.vue
@@ -0,0 +1,185 @@
+<template>
+<div>
+ <MkLoading v-if="fetching"/>
+ <div v-show="!fetching" :class="$style.root">
+ <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="pies">
+ <div class="pie deliver _panel">
+ <div class="title">Sub</div>
+ <XPie :data="topSubInstancesForPie" class="chart"/>
+ <div class="subTitle">Top 10</div>
+ </div>
+ <div class="pie inbox _panel">
+ <div class="title">Pub</div>
+ <XPie :data="topPubInstancesForPie" class="chart"/>
+ <div class="subTitle">Top 10</div>
+ </div>
+ </div>
+ <div v-if="!fetching" class="items">
+ <div class="item _panel sub">
+ <div class="icon"><i class="ti ti-world-download"></i></div>
+ <div class="body">
+ <div class="value">
+ {{ number(federationSubActive) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff>
+ </div>
+ <div class="label">Sub</div>
+ </div>
+ </div>
+ <div class="item _panel pub">
+ <div class="icon"><i class="ti ti-world-upload"></i></div>
+ <div class="body">
+ <div class="value">
+ {{ number(federationPubActive) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff>
+ </div>
+ <div class="label">Pub</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import XPie from './overview.pie.vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import MkNumberDiff from '@/components/MkNumberDiff.vue';
+import { i18n } from '@/i18n';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+
+let topSubInstancesForPie: any = $ref(null);
+let topPubInstancesForPie: any = $ref(null);
+let federationPubActive = $ref<number | null>(null);
+let federationPubActiveDiff = $ref<number | null>(null);
+let federationSubActive = $ref<number | null>(null);
+let federationSubActiveDiff = $ref<number | null>(null);
+let fetching = $ref(true);
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+onMounted(async () => {
+ const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' });
+ federationPubActive = chart.pubActive[0];
+ federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
+ federationSubActive = chart.subActive[0];
+ federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
+
+ os.apiGet('federation/stats', { limit: 10 }).then(res => {
+ topSubInstancesForPie = res.topSubInstances.map(x => ({
+ name: x.host,
+ color: x.themeColor,
+ value: x.followersCount,
+ onClick: () => {
+ os.pageWindow(`/instance-info/${x.host}`);
+ },
+ })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
+ topPubInstancesForPie = res.topPubInstances.map(x => ({
+ name: x.host,
+ color: x.themeColor,
+ value: x.followingCount,
+ onClick: () => {
+ os.pageWindow(`/instance-info/${x.host}`);
+ },
+ })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
+ });
+
+ fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+
+ &:global {
+ > .pies {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+ margin-bottom: 12px;
+
+ > .pie {
+ position: relative;
+ padding: 12px;
+
+ > .title {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ font-size: 90%;
+ }
+
+ > .chart {
+ max-height: 150px;
+ }
+
+ > .subTitle {
+ position: absolute;
+ bottom: 20px;
+ right: 20px;
+ font-size: 85%;
+ }
+ }
+ }
+
+ > .items {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+
+ > .item {
+ display: flex;
+ box-sizing: border-box;
+ padding: 12px;
+
+ > .icon {
+ display: grid;
+ place-items: center;
+ height: 100%;
+ aspect-ratio: 1;
+ margin-right: 12px;
+ background: var(--accentedBg);
+ color: var(--accent);
+ border-radius: 10px;
+ }
+
+ &.sub {
+ > .icon {
+ background: #d5ba0026;
+ color: #dfc300;
+ }
+ }
+
+ &.pub {
+ > .icon {
+ background: #00cf2326;
+ color: #00cd5b;
+ }
+ }
+
+ > .body {
+ padding: 2px 0;
+
+ > .value {
+ font-size: 1.25em;
+ font-weight: bold;
+
+ > .diff {
+ font-size: 0.65em;
+ font-weight: normal;
+ }
+ }
+
+ > .label {
+ font-size: 0.8em;
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
+
diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue
new file mode 100644
index 0000000000..16d1c83b9f
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.heatmap.vue
@@ -0,0 +1,15 @@
+<template>
+<div class="_panel" :class="$style.root">
+ <MkActiveUsersHeatmap/>
+</div>
+</template>
+
+<script lang="ts" setup>
+import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 20px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue
new file mode 100644
index 0000000000..29848bf03b
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.instances.vue
@@ -0,0 +1,50 @@
+<template>
+<div class="wbrkwale">
+ <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+ <MkLoading v-if="fetching"/>
+ <div v-else class="instances">
+ <MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" class="instance">
+ <MkInstanceCardMini :instance="instance"/>
+ </MkA>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
+
+const instances = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+ const fetchedInstances = await os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 6,
+ });
+ instances.value = fetchedInstances;
+ fetching.value = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+ immediate: true,
+ afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwale {
+ > .instances {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ grid-gap: 12px;
+
+ > .instance:hover {
+ text-decoration: none;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue
new file mode 100644
index 0000000000..a1f63c8711
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.moderators.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+ <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+ <MkLoading v-if="fetching"/>
+ <div v-else :class="$style.root" class="_panel">
+ <MkA v-for="user in moderators" :key="user.id" class="user" :to="`/user-info/${user.id}`">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true" :disable-link="true"/>
+ </MkA>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+
+let moderators: any = $ref(null);
+let fetching = $ref(true);
+
+onMounted(async () => {
+ moderators = await os.api('admin/show-users', {
+ sort: '+lastActiveDate',
+ state: 'adminOrModerator',
+ limit: 30,
+ });
+
+ fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
+ grid-gap: 12px;
+ place-content: center;
+ padding: 12px;
+
+ &:global {
+ > .user {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 1;
+
+ > .avatar {
+ width: 100%;
+ height: 100%;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue
new file mode 100644
index 0000000000..94509cf006
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.pie.vue
@@ -0,0 +1,110 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ DoughnutController,
+} from 'chart.js';
+import number from '@/filters/number';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ DoughnutController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const props = defineProps<{
+ data: { name: string; value: number; color: string; onClick?: () => void }[];
+}>();
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip({
+ position: 'middle',
+});
+
+let chartInstance: Chart;
+
+onMounted(() => {
+ chartInstance = new Chart(chartEl.value, {
+ type: 'doughnut',
+ data: {
+ labels: props.data.map(x => x.name),
+ datasets: [{
+ backgroundColor: props.data.map(x => x.color),
+ borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
+ borderWidth: 2,
+ hoverOffset: 0,
+ data: props.data.map(x => x.value),
+ }],
+ },
+ options: {
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 16,
+ },
+ },
+ onClick: (ev) => {
+ const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0];
+ if (hit && props.data[hit.index].onClick) {
+ props.data[hit.index].onClick();
+ }
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ },
+ },
+ });
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue
new file mode 100644
index 0000000000..1e095bddaa
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue
@@ -0,0 +1,186 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { watch, onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const props = defineProps<{
+ type: string;
+}>();
+
+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})`;
+};
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+let chartInstance: Chart;
+
+function setData(values) {
+ if (chartInstance == null) return;
+ for (const value of values) {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(value);
+ if (chartInstance.data.datasets[0].data.length > 100) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ }
+ }
+ chartInstance.update();
+}
+
+function pushData(value) {
+ if (chartInstance == null) return;
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(value);
+ if (chartInstance.data.datasets[0].data.length > 100) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ }
+ chartInstance.update();
+}
+
+const label =
+ props.type === 'process' ? 'Process' :
+ props.type === 'active' ? 'Active' :
+ props.type === 'delayed' ? 'Delayed' :
+ props.type === 'waiting' ? 'Waiting' :
+ '?' as never;
+
+const color =
+ props.type === 'process' ? '#00E396' :
+ props.type === 'active' ? '#00BCD4' :
+ props.type === 'delayed' ? '#E53935' :
+ props.type === 'waiting' ? '#FFB300' :
+ '?' as never;
+
+onMounted(() => {
+ const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+ chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: label,
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: color,
+ backgroundColor: alpha(color, 0.2),
+ fill: true,
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ grid: {
+ display: false,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: false,
+ maxTicksLimit: 10,
+ },
+ },
+ y: {
+ min: 0,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ },
+ },
+ plugins: [chartVLine(vLineColor)],
+ });
+});
+
+defineExpose({
+ setData,
+ pushData,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue
new file mode 100644
index 0000000000..72ebddc72f
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.queue.vue
@@ -0,0 +1,127 @@
+<template>
+<div :class="$style.root">
+ <div class="_table status">
+ <div class="_row">
+ <div class="_cell" style="text-align: center;"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
+ <div class="_cell" style="text-align: center;"><div class="_label">Active</div>{{ number(active) }}</div>
+ <div class="_cell" style="text-align: center;"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
+ <div class="_cell" style="text-align: center;"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+ </div>
+ </div>
+ <div class="charts">
+ <div class="chart">
+ <div class="title">Process</div>
+ <XChart ref="chartProcess" type="process"/>
+ </div>
+ <div class="chart">
+ <div class="title">Active</div>
+ <XChart ref="chartActive" type="active"/>
+ </div>
+ <div class="chart">
+ <div class="title">Delayed</div>
+ <XChart ref="chartDelayed" type="delayed"/>
+ </div>
+ <div class="chart">
+ <div class="title">Waiting</div>
+ <XChart ref="chartWaiting" type="waiting"/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, onMounted, onUnmounted, ref } from 'vue';
+import XChart from './overview.queue.chart.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const connection = markRaw(stream.useChannel('queueStats'));
+
+const activeSincePrevTick = ref(0);
+const active = ref(0);
+const delayed = ref(0);
+const waiting = ref(0);
+let chartProcess = $ref<InstanceType<typeof XChart>>();
+let chartActive = $ref<InstanceType<typeof XChart>>();
+let chartDelayed = $ref<InstanceType<typeof XChart>>();
+let chartWaiting = $ref<InstanceType<typeof XChart>>();
+
+const props = defineProps<{
+ domain: string;
+}>();
+
+const onStats = (stats) => {
+ activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+ active.value = stats[props.domain].active;
+ delayed.value = stats[props.domain].delayed;
+ waiting.value = stats[props.domain].waiting;
+
+ chartProcess.pushData(stats[props.domain].activeSincePrevTick);
+ chartActive.pushData(stats[props.domain].active);
+ chartDelayed.pushData(stats[props.domain].delayed);
+ chartWaiting.pushData(stats[props.domain].waiting);
+};
+
+const onStatsLog = (statsLog) => {
+ const dataProcess = [];
+ const dataActive = [];
+ const dataDelayed = [];
+ const dataWaiting = [];
+
+ for (const stats of [...statsLog].reverse()) {
+ dataProcess.push(stats[props.domain].activeSincePrevTick);
+ dataActive.push(stats[props.domain].active);
+ dataDelayed.push(stats[props.domain].delayed);
+ dataWaiting.push(stats[props.domain].waiting);
+ }
+
+ chartProcess.setData(dataProcess);
+ chartActive.setData(dataActive);
+ chartDelayed.setData(dataDelayed);
+ chartWaiting.setData(dataWaiting);
+};
+
+onMounted(() => {
+ connection.on('stats', onStats);
+ connection.on('statsLog', onStatsLog);
+ connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 100,
+ });
+});
+
+onUnmounted(() => {
+ connection.off('stats', onStats);
+ connection.off('statsLog', onStatsLog);
+ connection.dispose();
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ &:global {
+ > .status {
+ padding: 0 0 16px 0;
+ }
+
+ > .charts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+
+ > .chart {
+ min-width: 0;
+ padding: 16px;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ font-size: 0.85em;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue
new file mode 100644
index 0000000000..feac6f8118
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.retention.vue
@@ -0,0 +1,49 @@
+<template>
+<div>
+ <MkLoading v-if="fetching"/>
+ <div v-else :class="$style.root">
+ <div v-for="row in retention" class="row">
+ <div v-for="value in getValues(row)" v-tooltip="value.percentage" class="cell">
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import { i18n } from '@/i18n';
+
+let retention: any = $ref(null);
+let fetching = $ref(true);
+
+function getValues(row) {
+ const data = [];
+ for (const key in row.data) {
+ data.push({
+ date: new Date(key),
+ value: number(row.data[key]),
+ percentage: `${Math.ceil(row.data[key] / row.users) * 100}%`,
+ });
+ }
+ data.sort((a, b) => a.date > b.date);
+ return data;
+}
+
+onMounted(async () => {
+ retention = await os.apiGet('retention', {});
+
+ fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+
+ &:global {
+
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue
new file mode 100644
index 0000000000..4dcf7e751a
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.stats.vue
@@ -0,0 +1,155 @@
+<template>
+<div>
+ <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+ <MkLoading v-if="fetching"/>
+ <div v-else :class="$style.root">
+ <div class="item _panel users">
+ <div class="icon"><i class="ti ti-users"></i></div>
+ <div class="body">
+ <div class="value">
+ {{ number(stats.originalUsersCount) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff>
+ </div>
+ <div class="label">Users</div>
+ </div>
+ </div>
+ <div class="item _panel notes">
+ <div class="icon"><i class="ti ti-pencil"></i></div>
+ <div class="body">
+ <div class="value">
+ {{ number(stats.originalNotesCount) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff>
+ </div>
+ <div class="label">Notes</div>
+ </div>
+ </div>
+ <div class="item _panel instances">
+ <div class="icon"><i class="ti ti-planet"></i></div>
+ <div class="body">
+ <div class="value">
+ {{ number(stats.instances) }}
+ </div>
+ <div class="label">Instances</div>
+ </div>
+ </div>
+ <div class="item _panel online">
+ <div class="icon"><i class="ti ti-access-point"></i></div>
+ <div class="body">
+ <div class="value">
+ {{ number(onlineUsersCount) }}
+ </div>
+ <div class="label">Online</div>
+ </div>
+ </div>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import MkNumberDiff from '@/components/MkNumberDiff.vue';
+import { i18n } from '@/i18n';
+
+let stats: any = $ref(null);
+let usersComparedToThePrevDay = $ref<number>();
+let notesComparedToThePrevDay = $ref<number>();
+let onlineUsersCount = $ref(0);
+let fetching = $ref(true);
+
+onMounted(async () => {
+ const [_stats, _onlineUsersCount] = await Promise.all([
+ os.api('stats', {}),
+ os.api('get-online-users-count').then(res => res.count),
+ ]);
+ stats = _stats;
+ onlineUsersCount = _onlineUsersCount;
+
+ os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => {
+ usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1];
+ });
+
+ os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+ notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1];
+ });
+
+ fetching = false;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+
+ &:global {
+ > .item {
+ display: flex;
+ box-sizing: border-box;
+ padding: 12px;
+
+ > .icon {
+ display: grid;
+ place-items: center;
+ height: 100%;
+ aspect-ratio: 1;
+ margin-right: 12px;
+ background: var(--accentedBg);
+ color: var(--accent);
+ border-radius: 10px;
+ }
+
+ &.users {
+ > .icon {
+ background: #0088d726;
+ color: #3d96c1;
+ }
+ }
+
+ &.notes {
+ > .icon {
+ background: #86b30026;
+ color: #86b300;
+ }
+ }
+
+ &.instances {
+ > .icon {
+ background: #e96b0026;
+ color: #d76d00;
+ }
+ }
+
+ &.online {
+ > .icon {
+ background: #8a00d126;
+ color: #c01ac3;
+ }
+ }
+
+ > .body {
+ padding: 2px 0;
+
+ > .value {
+ font-size: 1.25em;
+ font-weight: bold;
+
+ > .diff {
+ font-size: 0.65em;
+ font-weight: normal;
+ }
+ }
+
+ > .label {
+ font-size: 0.8em;
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue
new file mode 100644
index 0000000000..5d4be11742
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.users.vue
@@ -0,0 +1,57 @@
+<template>
+<div :class="$style.root">
+ <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
+ <MkLoading v-if="fetching"/>
+ <div v-else class="users">
+ <MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/user-info/${user.id}`" class="user">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+
+let newUsers = $ref(null);
+let fetching = $ref(true);
+
+const fetch = async () => {
+ const _newUsers = await os.api('admin/show-users', {
+ limit: 5,
+ sort: '+createdAt',
+ origin: 'local',
+ });
+ newUsers = _newUsers;
+ fetching = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+ immediate: true,
+ afterMounted: true,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ &:global {
+ > .users {
+ .chart-move {
+ transition: transform 1s ease;
+ }
+
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ grid-gap: 12px;
+
+ > .user:hover {
+ text-decoration: none;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue
new file mode 100644
index 0000000000..d656e55200
--- /dev/null
+++ b/packages/frontend/src/pages/admin/overview.vue
@@ -0,0 +1,190 @@
+<template>
+<MkSpacer :content-max="1000">
+ <div ref="rootEl" class="edbbcaef">
+ <MkFolder class="item">
+ <template #header>Stats</template>
+ <XStats/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Active users</template>
+ <XActiveUsers/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Heatmap</template>
+ <XHeatmap/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Retention rate</template>
+ <XRetention/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Moderators</template>
+ <XModerators/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Federation</template>
+ <XFederation/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Instances</template>
+ <XInstances/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Ap requests</template>
+ <XApRequests/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>New users</template>
+ <XUsers/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Deliver queue</template>
+ <XQueue domain="deliver"/>
+ </MkFolder>
+
+ <MkFolder class="item">
+ <template #header>Inbox queue</template>
+ <XQueue domain="inbox"/>
+ </MkFolder>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import XFederation from './overview.federation.vue';
+import XInstances from './overview.instances.vue';
+import XQueue from './overview.queue.vue';
+import XApRequests from './overview.ap-requests.vue';
+import XUsers from './overview.users.vue';
+import XActiveUsers from './overview.active-users.vue';
+import XStats from './overview.stats.vue';
+import XRetention from './overview.retention.vue';
+import XModerators from './overview.moderators.vue';
+import XHeatmap from './overview.heatmap.vue';
+import MkTagCloud from '@/components/MkTagCloud.vue';
+import { version, url } from '@/config';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
+import MkFolder from '@/components/MkFolder.vue';
+
+const rootEl = $ref<HTMLElement>();
+let serverInfo: any = $ref(null);
+let topSubInstancesForPie: any = $ref(null);
+let topPubInstancesForPie: any = $ref(null);
+let federationPubActive = $ref<number | null>(null);
+let federationPubActiveDiff = $ref<number | null>(null);
+let federationSubActive = $ref<number | null>(null);
+let federationSubActiveDiff = $ref<number | null>(null);
+let newUsers = $ref(null);
+let activeInstances = $shallowRef(null);
+const queueStatsConnection = markRaw(stream.useChannel('queueStats'));
+const now = new Date();
+const filesPagination = {
+ endpoint: 'admin/drive/files' as const,
+ limit: 9,
+ noPaging: true,
+};
+
+function onInstanceClick(i) {
+ os.pageWindow(`/instance-info/${i.host}`);
+}
+
+onMounted(async () => {
+ /*
+ const magicGrid = new MagicGrid({
+ container: rootEl,
+ static: true,
+ animate: true,
+ });
+
+ magicGrid.listen();
+ */
+
+ os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => {
+ federationPubActive = chart.pubActive[0];
+ federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1];
+ federationSubActive = chart.subActive[0];
+ federationSubActiveDiff = chart.subActive[0] - chart.subActive[1];
+ });
+
+ os.apiGet('federation/stats', { limit: 10 }).then(res => {
+ topSubInstancesForPie = res.topSubInstances.map(x => ({
+ name: x.host,
+ color: x.themeColor,
+ value: x.followersCount,
+ onClick: () => {
+ os.pageWindow(`/instance-info/${x.host}`);
+ },
+ })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]);
+ topPubInstancesForPie = res.topPubInstances.map(x => ({
+ name: x.host,
+ color: x.themeColor,
+ value: x.followingCount,
+ onClick: () => {
+ os.pageWindow(`/instance-info/${x.host}`);
+ },
+ })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]);
+ });
+
+ os.api('admin/server-info').then(serverInfoResponse => {
+ serverInfo = serverInfoResponse;
+ });
+
+ os.api('admin/show-users', {
+ limit: 5,
+ sort: '+createdAt',
+ }).then(res => {
+ newUsers = res;
+ });
+
+ os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 25,
+ }).then(res => {
+ activeInstances = res;
+ });
+
+ nextTick(() => {
+ queueStatsConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 100,
+ });
+ });
+});
+
+onBeforeUnmount(() => {
+ queueStatsConnection.dispose();
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.dashboard,
+ icon: 'ti ti-dashboard',
+});
+</script>
+
+<style lang="scss" scoped>
+.edbbcaef {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+ grid-gap: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue
new file mode 100644
index 0000000000..5d0d67980e
--- /dev/null
+++ b/packages/frontend/src/pages/admin/proxy-account.vue
@@ -0,0 +1,62 @@
+<template><MkStickyContainer>
+ <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo>
+ <MkKeyValue class="_formBlock">
+ <template #key>{{ i18n.ts.proxyAccount }}</template>
+ <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
+ </MkKeyValue>
+
+ <FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
+ </FormSuspense>
+</MkSpacer></MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import FormButton from '@/components/MkButton.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let proxyAccount: any = $ref(null);
+let proxyAccountId: any = $ref(null);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ proxyAccountId = meta.proxyAccountId;
+ if (proxyAccountId) {
+ proxyAccount = await os.api('users/show', { userId: proxyAccountId });
+ }
+}
+
+function chooseProxyAccount() {
+ os.selectUser().then(user => {
+ proxyAccount = user;
+ proxyAccountId = user.id;
+ save();
+ });
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ proxyAccountId: proxyAccountId,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.proxyAccount,
+ icon: 'ti ti-ghost',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue
new file mode 100644
index 0000000000..5777674ae3
--- /dev/null
+++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue
@@ -0,0 +1,186 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { watch, onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import { chartVLine } from '@/scripts/chart-vline';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const props = defineProps<{
+ type: string;
+}>();
+
+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})`;
+};
+
+const chartEl = ref<HTMLCanvasElement>(null);
+
+const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+// フォントカラー
+Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+const { handler: externalTooltipHandler } = useChartTooltip();
+
+let chartInstance: Chart;
+
+function setData(values) {
+ if (chartInstance == null) return;
+ for (const value of values) {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(value);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ }
+ }
+ chartInstance.update();
+}
+
+function pushData(value) {
+ if (chartInstance == null) return;
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(value);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ }
+ chartInstance.update();
+}
+
+const label =
+ props.type === 'process' ? 'Process' :
+ props.type === 'active' ? 'Active' :
+ props.type === 'delayed' ? 'Delayed' :
+ props.type === 'waiting' ? 'Waiting' :
+ '?' as never;
+
+const color =
+ props.type === 'process' ? '#00E396' :
+ props.type === 'active' ? '#00BCD4' :
+ props.type === 'delayed' ? '#E53935' :
+ props.type === 'waiting' ? '#FFB300' :
+ '?' as never;
+
+onMounted(() => {
+ const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+
+ chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: label,
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: color,
+ backgroundColor: alpha(color, 0.2),
+ fill: true,
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: false,
+ maxTicksLimit: 10,
+ },
+ },
+ y: {
+ min: 0,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ },
+ },
+ plugins: [chartVLine(vLineColor)],
+ });
+});
+
+defineExpose({
+ setData,
+ pushData,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue
new file mode 100644
index 0000000000..186a22c43e
--- /dev/null
+++ b/packages/frontend/src/pages/admin/queue.chart.vue
@@ -0,0 +1,149 @@
+<template>
+<div class="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="charts">
+ <div class="chart">
+ <div class="title">Process</div>
+ <XChart ref="chartProcess" type="process"/>
+ </div>
+ <div class="chart">
+ <div class="title">Active</div>
+ <XChart ref="chartActive" type="active"/>
+ </div>
+ <div class="chart">
+ <div class="title">Delayed</div>
+ <XChart ref="chartDelayed" type="delayed"/>
+ </div>
+ <div class="chart">
+ <div class="title">Waiting</div>
+ <XChart ref="chartWaiting" type="waiting"/>
+ </div>
+ </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;">{{ i18n.ts.noJobs }}</span>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, onMounted, onUnmounted, ref } from 'vue';
+import XChart from './queue.chart.chart.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const connection = markRaw(stream.useChannel('queueStats'));
+
+const activeSincePrevTick = ref(0);
+const active = ref(0);
+const delayed = ref(0);
+const waiting = ref(0);
+const jobs = ref([]);
+let chartProcess = $ref<InstanceType<typeof XChart>>();
+let chartActive = $ref<InstanceType<typeof XChart>>();
+let chartDelayed = $ref<InstanceType<typeof XChart>>();
+let chartWaiting = $ref<InstanceType<typeof XChart>>();
+
+const props = defineProps<{
+ domain: string;
+}>();
+
+const onStats = (stats) => {
+ activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+ active.value = stats[props.domain].active;
+ delayed.value = stats[props.domain].delayed;
+ waiting.value = stats[props.domain].waiting;
+
+ chartProcess.pushData(stats[props.domain].activeSincePrevTick);
+ chartActive.pushData(stats[props.domain].active);
+ chartDelayed.pushData(stats[props.domain].delayed);
+ chartWaiting.pushData(stats[props.domain].waiting);
+};
+
+const onStatsLog = (statsLog) => {
+ const dataProcess = [];
+ const dataActive = [];
+ const dataDelayed = [];
+ const dataWaiting = [];
+
+ for (const stats of [...statsLog].reverse()) {
+ dataProcess.push(stats[props.domain].activeSincePrevTick);
+ dataActive.push(stats[props.domain].active);
+ dataDelayed.push(stats[props.domain].delayed);
+ dataWaiting.push(stats[props.domain].waiting);
+ }
+
+ chartProcess.setData(dataProcess);
+ chartActive.setData(dataActive);
+ chartDelayed.setData(dataDelayed);
+ chartWaiting.setData(dataWaiting);
+};
+
+onMounted(() => {
+ os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
+ jobs.value = result;
+ });
+
+ connection.on('stats', onStats);
+ connection.on('statsLog', onStatsLog);
+ connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200,
+ });
+});
+
+onUnmounted(() => {
+ connection.off('stats', onStats);
+ connection.off('statsLog', onStatsLog);
+ connection.dispose();
+});
+</script>
+
+<style lang="scss" scoped>
+.pumxzjhg {
+ > .status {
+ padding: 16px;
+ }
+
+ > .charts {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+
+ > .chart {
+ min-width: 0;
+ padding: 16px;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ margin-bottom: 8px;
+ }
+ }
+ }
+
+ > .jobs {
+ margin-top: 16px;
+ padding: 16px;
+ max-height: 180px;
+ overflow: auto;
+ background: var(--panel);
+ border-radius: var(--radius);
+ }
+
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue
new file mode 100644
index 0000000000..8d19b49fc5
--- /dev/null
+++ b/packages/frontend/src/pages/admin/queue.vue
@@ -0,0 +1,56 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <XQueue v-if="tab === 'deliver'" domain="deliver"/>
+ <XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue';
+import XQueue from './queue.chart.vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import * as config from '@/config';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let tab = $ref('deliver');
+
+function clear() {
+ os.confirm({
+ type: 'warning',
+ title: i18n.ts.clearQueueConfirmTitle,
+ text: i18n.ts.clearQueueConfirmText,
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/queue/clear');
+ });
+}
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-external-link',
+ text: i18n.ts.dashboard,
+ handler: () => {
+ window.open(config.url + '/queue', '_blank');
+ },
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'deliver',
+ title: 'Deliver',
+}, {
+ key: 'inbox',
+ title: 'Inbox',
+}]);
+
+definePageMetadata({
+ title: i18n.ts.jobQueue,
+ icon: 'ti ti-clock-play',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue
new file mode 100644
index 0000000000..4768ae67b1
--- /dev/null
+++ b/packages/frontend/src/pages/admin/relays.vue
@@ -0,0 +1,103 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="800">
+ <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;">
+ <div>{{ relay.inbox }}</div>
+ <div class="status">
+ <i v-if="relay.status === 'accepted'" class="ti ti-check icon accepted"></i>
+ <i v-else-if="relay.status === 'rejected'" class="ti ti-ban icon rejected"></i>
+ <i v-else class="ti ti-clock icon requesting"></i>
+ <span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+ </div>
+ <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let relays: any[] = $ref([]);
+
+async function addRelay() {
+ const { canceled, result: inbox } = await os.inputText({
+ title: i18n.ts.addRelay,
+ type: 'url',
+ placeholder: i18n.ts.inboxUrl,
+ });
+ if (canceled) return;
+ os.api('admin/relays/add', {
+ inbox,
+ }).then((relay: any) => {
+ refresh();
+ }).catch((err: any) => {
+ os.alert({
+ type: 'error',
+ text: err.message || err,
+ });
+ });
+}
+
+function remove(inbox: string) {
+ os.api('admin/relays/remove', {
+ inbox,
+ }).then(() => {
+ refresh();
+ }).catch((err: any) => {
+ os.alert({
+ type: 'error',
+ text: err.message || err,
+ });
+ });
+}
+
+function refresh() {
+ os.api('admin/relays/list').then((relayList: any) => {
+ relays = relayList;
+ });
+}
+
+refresh();
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-plus',
+ text: i18n.ts.addRelay,
+ handler: addRelay,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.relays,
+ icon: 'ti ti-planet',
+});
+</script>
+
+<style lang="scss" scoped>
+.relaycxt {
+ > .status {
+ margin: 8px 0;
+
+ > .icon {
+ width: 1em;
+ margin-right: 0.75em;
+
+ &.accepted {
+ color: var(--success);
+ }
+
+ &.rejected {
+ color: var(--error);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue
new file mode 100644
index 0000000000..2682bda337
--- /dev/null
+++ b/packages/frontend/src/pages/admin/security.vue
@@ -0,0 +1,179 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormFolder class="_formBlock">
+ <template #icon><i class="ti ti-shield"></i></template>
+ <template #label>{{ i18n.ts.botProtection }}</template>
+ <template v-if="enableHcaptcha" #suffix>hCaptcha</template>
+ <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
+ <template v-else-if="enableTurnstile" #suffix>Turnstile</template>
+ <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
+
+ <XBotProtection/>
+ </FormFolder>
+
+ <FormFolder class="_formBlock">
+ <template #icon><i class="ti ti-eye-off"></i></template>
+ <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
+ <template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
+ <template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
+ <template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
+ <template v-else #suffix>{{ i18n.ts.none }}</template>
+
+ <div class="_formRoot">
+ <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span>
+
+ <FormRadios v-model="sensitiveMediaDetection" class="_formBlock">
+ <option value="none">{{ i18n.ts.none }}</option>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.localOnly }}</option>
+ <option value="remote">{{ i18n.ts.remoteOnly }}</option>
+ </FormRadios>
+
+ <FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
+ <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
+ </FormRange>
+
+ <FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
+ <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
+ <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
+ </FormSwitch>
+
+ <!-- 現状 false positive が多すぎて実用に耐えない
+ <FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock">
+ <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
+ </FormSwitch>
+ -->
+
+ <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+ </FormFolder>
+
+ <FormFolder class="_formBlock">
+ <template #label>Active Email Validation</template>
+ <template v-if="enableActiveEmailValidation" #suffix>Enabled</template>
+ <template v-else #suffix>Disabled</template>
+
+ <div class="_formRoot">
+ <span class="_formBlock">{{ i18n.ts.activeEmailValidationDescription }}</span>
+ <FormSwitch v-model="enableActiveEmailValidation" class="_formBlock" @update:model-value="save">
+ <template #label>Enable</template>
+ </FormSwitch>
+ </div>
+ </FormFolder>
+
+ <FormFolder class="_formBlock">
+ <template #label>Log IP address</template>
+ <template v-if="enableIpLogging" #suffix>Enabled</template>
+ <template v-else #suffix>Disabled</template>
+
+ <div class="_formRoot">
+ <FormSwitch v-model="enableIpLogging" class="_formBlock" @update:model-value="save">
+ <template #label>Enable</template>
+ </FormSwitch>
+ </div>
+ </FormFolder>
+
+ <FormFolder class="_formBlock">
+ <template #label>Summaly Proxy</template>
+
+ <div class="_formRoot">
+ <FormInput v-model="summalyProxy" class="_formBlock">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>Summaly Proxy URL</template>
+ </FormInput>
+
+ <FormButton primary class="_formBlock" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</FormButton>
+ </div>
+ </FormFolder>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XBotProtection from './bot-protection.vue';
+import XHeader from './_header_.vue';
+import FormFolder from '@/components/form/folder.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import FormRange from '@/components/form/range.vue';
+import FormInput from '@/components/form/input.vue';
+import FormButton from '@/components/MkButton.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let summalyProxy: string = $ref('');
+let enableHcaptcha: boolean = $ref(false);
+let enableRecaptcha: boolean = $ref(false);
+let enableTurnstile: boolean = $ref(false);
+let sensitiveMediaDetection: string = $ref('none');
+let sensitiveMediaDetectionSensitivity: number = $ref(0);
+let setSensitiveFlagAutomatically: boolean = $ref(false);
+let enableSensitiveMediaDetectionForVideos: boolean = $ref(false);
+let enableIpLogging: boolean = $ref(false);
+let enableActiveEmailValidation: boolean = $ref(false);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ summalyProxy = meta.summalyProxy;
+ enableHcaptcha = meta.enableHcaptcha;
+ enableRecaptcha = meta.enableRecaptcha;
+ enableTurnstile = meta.enableTurnstile;
+ sensitiveMediaDetection = meta.sensitiveMediaDetection;
+ sensitiveMediaDetectionSensitivity =
+ meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
+ meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
+ meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
+ meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
+ meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0;
+ setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically;
+ enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos;
+ enableIpLogging = meta.enableIpLogging;
+ enableActiveEmailValidation = meta.enableActiveEmailValidation;
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ summalyProxy,
+ sensitiveMediaDetection,
+ sensitiveMediaDetectionSensitivity:
+ sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' :
+ sensitiveMediaDetectionSensitivity === 1 ? 'low' :
+ sensitiveMediaDetectionSensitivity === 2 ? 'medium' :
+ sensitiveMediaDetectionSensitivity === 3 ? 'high' :
+ sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' :
+ 0,
+ setSensitiveFlagAutomatically,
+ enableSensitiveMediaDetectionForVideos,
+ enableIpLogging,
+ enableActiveEmailValidation,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.security,
+ icon: 'ti ti-lock',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
new file mode 100644
index 0000000000..460eb92694
--- /dev/null
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -0,0 +1,262 @@
+<template>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormSuspense :p="init">
+ <div class="_formRoot">
+ <FormInput v-model="name" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceName }}</template>
+ </FormInput>
+
+ <FormTextarea v-model="description" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceDescription }}</template>
+ </FormTextarea>
+
+ <FormInput v-model="tosUrl" class="_formBlock">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>{{ i18n.ts.tosUrl }}</template>
+ </FormInput>
+
+ <FormSplit :min-width="300">
+ <FormInput v-model="maintainerName" class="_formBlock">
+ <template #label>{{ i18n.ts.maintainerName }}</template>
+ </FormInput>
+
+ <FormInput v-model="maintainerEmail" type="email" class="_formBlock">
+ <template #prefix><i class="ti ti-mail"></i></template>
+ <template #label>{{ i18n.ts.maintainerEmail }}</template>
+ </FormInput>
+ </FormSplit>
+
+ <FormTextarea v-model="pinnedUsers" class="_formBlock">
+ <template #label>{{ i18n.ts.pinnedUsers }}</template>
+ <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
+ </FormTextarea>
+
+ <FormSection>
+ <FormSwitch v-model="enableRegistration" class="_formBlock">
+ <template #label>{{ i18n.ts.enableRegistration }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="emailRequiredForSignup" class="_formBlock">
+ <template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
+ </FormSwitch>
+ </FormSection>
+
+ <FormSection>
+ <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
+ <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
+ <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.theme }}</template>
+
+ <FormInput v-model="iconUrl" class="_formBlock">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>{{ i18n.ts.iconUrl }}</template>
+ </FormInput>
+
+ <FormInput v-model="bannerUrl" class="_formBlock">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>{{ i18n.ts.bannerUrl }}</template>
+ </FormInput>
+
+ <FormInput v-model="backgroundImageUrl" class="_formBlock">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>{{ i18n.ts.backgroundImageUrl }}</template>
+ </FormInput>
+
+ <FormInput v-model="themeColor" class="_formBlock">
+ <template #prefix><i class="ti ti-palette"></i></template>
+ <template #label>{{ i18n.ts.themeColor }}</template>
+ <template #caption>#RRGGBB</template>
+ </FormInput>
+
+ <FormTextarea v-model="defaultLightTheme" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
+ <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+ </FormTextarea>
+
+ <FormTextarea v-model="defaultDarkTheme" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
+ <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
+ </FormTextarea>
+ </FormSection>
+
+ <FormSection>
+ <template #label>{{ i18n.ts.files }}</template>
+
+ <FormSwitch v-model="cacheRemoteFiles" class="_formBlock">
+ <template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
+ <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
+ </FormSwitch>
+
+ <FormSplit :min-width="280">
+ <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock">
+ <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template>
+ <template #suffix>MB</template>
+ <template #caption>{{ i18n.ts.inMb }}</template>
+ </FormInput>
+
+ <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock">
+ <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template>
+ <template #suffix>MB</template>
+ <template #caption>{{ i18n.ts.inMb }}</template>
+ </FormInput>
+ </FormSplit>
+ </FormSection>
+
+ <FormSection>
+ <template #label>ServiceWorker</template>
+
+ <FormSwitch v-model="enableServiceWorker" class="_formBlock">
+ <template #label>{{ i18n.ts.enableServiceworker }}</template>
+ <template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
+ </FormSwitch>
+
+ <template v-if="enableServiceWorker">
+ <FormInput v-model="swPublicKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Public key</template>
+ </FormInput>
+
+ <FormInput v-model="swPrivateKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Private key</template>
+ </FormInput>
+ </template>
+ </FormSection>
+
+ <FormSection>
+ <template #label>DeepL Translation</template>
+
+ <FormInput v-model="deeplAuthKey" class="_formBlock">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>DeepL Auth Key</template>
+ </FormInput>
+ <FormSwitch v-model="deeplIsPro" class="_formBlock">
+ <template #label>Pro account</template>
+ </FormSwitch>
+ </FormSection>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XHeader from './_header_.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormInput from '@/components/form/input.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormInfo from '@/components/MkInfo.vue';
+import FormSection from '@/components/form/section.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import * as os from '@/os';
+import { fetchInstance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+let name: string | null = $ref(null);
+let description: string | null = $ref(null);
+let tosUrl: string | null = $ref(null);
+let maintainerName: string | null = $ref(null);
+let maintainerEmail: string | null = $ref(null);
+let iconUrl: string | null = $ref(null);
+let bannerUrl: string | null = $ref(null);
+let backgroundImageUrl: string | null = $ref(null);
+let themeColor: any = $ref(null);
+let defaultLightTheme: any = $ref(null);
+let defaultDarkTheme: any = $ref(null);
+let enableLocalTimeline: boolean = $ref(false);
+let enableGlobalTimeline: boolean = $ref(false);
+let pinnedUsers: string = $ref('');
+let cacheRemoteFiles: boolean = $ref(false);
+let localDriveCapacityMb: any = $ref(0);
+let remoteDriveCapacityMb: any = $ref(0);
+let enableRegistration: boolean = $ref(false);
+let emailRequiredForSignup: boolean = $ref(false);
+let enableServiceWorker: boolean = $ref(false);
+let swPublicKey: any = $ref(null);
+let swPrivateKey: any = $ref(null);
+let deeplAuthKey: string = $ref('');
+let deeplIsPro: boolean = $ref(false);
+
+async function init() {
+ const meta = await os.api('admin/meta');
+ name = meta.name;
+ description = meta.description;
+ tosUrl = meta.tosUrl;
+ iconUrl = meta.iconUrl;
+ bannerUrl = meta.bannerUrl;
+ backgroundImageUrl = meta.backgroundImageUrl;
+ themeColor = meta.themeColor;
+ defaultLightTheme = meta.defaultLightTheme;
+ defaultDarkTheme = meta.defaultDarkTheme;
+ maintainerName = meta.maintainerName;
+ maintainerEmail = meta.maintainerEmail;
+ enableLocalTimeline = !meta.disableLocalTimeline;
+ enableGlobalTimeline = !meta.disableGlobalTimeline;
+ pinnedUsers = meta.pinnedUsers.join('\n');
+ cacheRemoteFiles = meta.cacheRemoteFiles;
+ localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
+ remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
+ enableRegistration = !meta.disableRegistration;
+ emailRequiredForSignup = meta.emailRequiredForSignup;
+ enableServiceWorker = meta.enableServiceWorker;
+ swPublicKey = meta.swPublickey;
+ swPrivateKey = meta.swPrivateKey;
+ deeplAuthKey = meta.deeplAuthKey;
+ deeplIsPro = meta.deeplIsPro;
+}
+
+function save() {
+ os.apiWithDialog('admin/update-meta', {
+ name,
+ description,
+ tosUrl,
+ iconUrl,
+ bannerUrl,
+ backgroundImageUrl,
+ themeColor: themeColor === '' ? null : themeColor,
+ defaultLightTheme: defaultLightTheme === '' ? null : defaultLightTheme,
+ defaultDarkTheme: defaultDarkTheme === '' ? null : defaultDarkTheme,
+ maintainerName,
+ maintainerEmail,
+ disableLocalTimeline: !enableLocalTimeline,
+ disableGlobalTimeline: !enableGlobalTimeline,
+ pinnedUsers: pinnedUsers.split('\n'),
+ cacheRemoteFiles,
+ localDriveCapacityMb: parseInt(localDriveCapacityMb, 10),
+ remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10),
+ disableRegistration: !enableRegistration,
+ emailRequiredForSignup,
+ enableServiceWorker,
+ swPublicKey,
+ swPrivateKey,
+ deeplAuthKey,
+ deeplIsPro,
+ }).then(() => {
+ fetchInstance();
+ });
+}
+
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'ti ti-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.general,
+ icon: 'ti ti-settings',
+});
+</script>
diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue
new file mode 100644
index 0000000000..d466e21907
--- /dev/null
+++ b/packages/frontend/src/pages/admin/users.vue
@@ -0,0 +1,170 @@
+<template>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="900">
+ <div class="lknzcolw">
+ <div class="users">
+ <div class="inputs">
+ <MkSelect v-model="sort" style="flex: 1;">
+ <template #label>{{ i18n.ts.sort }}</template>
+ <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option>
+ <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option>
+ <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option>
+ <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option>
+ </MkSelect>
+ <MkSelect v-model="state" style="flex: 1;">
+ <template #label>{{ i18n.ts.state }}</template>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="available">{{ i18n.ts.normal }}</option>
+ <option value="admin">{{ i18n.ts.administrator }}</option>
+ <option value="moderator">{{ i18n.ts.moderator }}</option>
+ <option value="silenced">{{ i18n.ts.silence }}</option>
+ <option value="suspended">{{ i18n.ts.suspend }}</option>
+ </MkSelect>
+ <MkSelect v-model="origin" style="flex: 1;">
+ <template #label>{{ i18n.ts.instance }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <div class="inputs">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:model-value="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ i18n.ts.username }}</template>
+ </MkInput>
+ <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:model-value="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ i18n.ts.host }}</template>
+ </MkInput>
+ </div>
+
+ <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
+ <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`">
+ <MkUserCardMini :user="user"/>
+ </MkA>
+ </MkPagination>
+ </div>
+ </div>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import XHeader from './_header_.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/MkPagination.vue';
+import * as os from '@/os';
+import { lookupUser } from '@/scripts/lookup-user';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+
+let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
+
+let sort = $ref('+createdAt');
+let state = $ref('all');
+let origin = $ref('local');
+let searchUsername = $ref('');
+let searchHost = $ref('');
+const pagination = {
+ endpoint: 'admin/show-users' as const,
+ limit: 10,
+ params: computed(() => ({
+ sort: sort,
+ state: state,
+ origin: origin,
+ username: searchUsername,
+ hostname: searchHost,
+ })),
+ offsetMode: true,
+};
+
+function searchUser() {
+ os.selectUser().then(user => {
+ show(user);
+ });
+}
+
+async function addUser() {
+ const { canceled: canceled1, result: username } = await os.inputText({
+ title: i18n.ts.username,
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: password } = await os.inputText({
+ title: i18n.ts.password,
+ type: 'password',
+ });
+ if (canceled2) return;
+
+ os.apiWithDialog('admin/accounts/create', {
+ username: username,
+ password: password,
+ }).then(res => {
+ paginationComponent.reload();
+ });
+}
+
+function show(user) {
+ os.pageWindow(`/user-info/${user.id}`);
+}
+
+const headerActions = $computed(() => [{
+ icon: 'ti ti-search',
+ text: i18n.ts.search,
+ handler: searchUser,
+}, {
+ asFullButton: true,
+ icon: 'ti ti-plus',
+ text: i18n.ts.addUser,
+ handler: addUser,
+}, {
+ asFullButton: true,
+ icon: 'ti ti-search',
+ text: i18n.ts.lookup,
+ handler: lookupUser,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.users,
+ icon: 'ti ti-users',
+})));
+</script>
+
+<style lang="scss" scoped>
+.lknzcolw {
+ > .users {
+
+ > .inputs {
+ display: flex;
+ margin-bottom: 16px;
+
+ > * {
+ margin-right: 16px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+
+ > .users {
+ margin-top: var(--margin);
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ grid-gap: 12px;
+
+ > .user:hover {
+ text-decoration: none;
+ }
+ }
+ }
+}
+</style>