summaryrefslogtreecommitdiff
path: root/packages/client/src/pages/admin
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-07-07 21:23:03 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-07-07 21:23:03 +0900
commit84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b (patch)
treea182502a5192992d873e7a7fcbf01662bb0dfca2 /packages/client/src/pages/admin
parentMerge pull request #8821 from misskey-dev/develop (diff)
parent12.112.1 (diff)
downloadmisskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.gz
misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.bz2
misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.zip
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages/admin')
-rw-r--r--packages/client/src/pages/admin/_header_.vue292
-rw-r--r--packages/client/src/pages/admin/abuses.vue83
-rw-r--r--packages/client/src/pages/admin/ads.vue102
-rw-r--r--packages/client/src/pages/admin/announcements.vue80
-rw-r--r--packages/client/src/pages/admin/bot-protection.vue12
-rw-r--r--packages/client/src/pages/admin/database.vue22
-rw-r--r--packages/client/src/pages/admin/email-settings.vue120
-rw-r--r--packages/client/src/pages/admin/emojis.vue181
-rw-r--r--packages/client/src/pages/admin/file-dialog.vue103
-rw-r--r--packages/client/src/pages/admin/files.vue177
-rw-r--r--packages/client/src/pages/admin/index.vue66
-rw-r--r--packages/client/src/pages/admin/instance-block.vue37
-rw-r--r--packages/client/src/pages/admin/integrations.vue26
-rw-r--r--packages/client/src/pages/admin/object-storage.vue137
-rw-r--r--packages/client/src/pages/admin/other-settings.vue40
-rw-r--r--packages/client/src/pages/admin/overview.federation.vue100
-rw-r--r--packages/client/src/pages/admin/overview.pie.vue108
-rw-r--r--packages/client/src/pages/admin/overview.queue-chart.vue211
-rw-r--r--packages/client/src/pages/admin/overview.user.vue76
-rw-r--r--packages/client/src/pages/admin/overview.vue665
-rw-r--r--packages/client/src/pages/admin/proxy-account.vue22
-rw-r--r--packages/client/src/pages/admin/queue.chart.chart.vue181
-rw-r--r--packages/client/src/pages/admin/queue.chart.vue142
-rw-r--r--packages/client/src/pages/admin/queue.vue67
-rw-r--r--packages/client/src/pages/admin/relays.vue62
-rw-r--r--packages/client/src/pages/admin/security.vue153
-rw-r--r--packages/client/src/pages/admin/settings.vue264
-rw-r--r--packages/client/src/pages/admin/users.vue209
28 files changed, 2574 insertions, 1164 deletions
diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue
new file mode 100644
index 0000000000..aea2663c39
--- /dev/null
+++ b/packages/client/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="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="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/ui/button.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/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
index e1d0361c0b..11cf284b22 100644
--- a/packages/client/src/pages/admin/abuses.vue
+++ b/packages/client/src/pages/admin/abuses.vue
@@ -1,56 +1,62 @@
<template>
-<div class="lcixvhis">
- <div class="_section reports">
- <div class="_content">
- <div class="inputs" style="display: flex;">
- <MkSelect v-model="state" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.state }}</template>
- <option value="all">{{ $ts.all }}</option>
- <option value="unresolved">{{ $ts.unresolved }}</option>
- <option value="resolved">{{ $ts.resolved }}</option>
- </MkSelect>
- <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.reporteeOrigin }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.reporterOrigin }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- </div>
- <!-- TODO
+<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>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="unresolved">{{ $ts.unresolved }}</option>
+ <option value="resolved">{{ $ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporteeOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporterOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <!-- TODO
<div class="inputs" style="display: flex; padding-top: 1.2em;">
- <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false">
+ <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
<span>{{ $ts.username }}</span>
</MkInput>
- <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" :disabled="pagination.params().origin === 'local'">
+ <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'">
<span>{{ $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>
+ <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>
- </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/ui/pagination.vue';
import XAbuseReport from '@/components/abuse-report.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let reports = $ref<InstanceType<typeof MkPagination>>();
@@ -74,12 +80,13 @@ function resolved(reportId) {
reports.removeItem(item => item.id === reportId);
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.abuseReports,
- icon: 'fas fa-exclamation-circle',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.abuseReports,
+ icon: 'fas fa-exclamation-circle',
});
</script>
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
index b18e08db96..21feafc0bb 100644
--- a/packages/client/src/pages/admin/ads.vue
+++ b/packages/client/src/pages/admin/ads.vue
@@ -1,21 +1,23 @@
<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>
- <!--
+<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>
@@ -23,36 +25,38 @@
<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="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+ <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="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
</div>
</div>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.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 * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let ads: any[] = $ref([]);
@@ -81,7 +85,7 @@ function remove(ad) {
if (canceled) return;
ads = ads.filter(x => x !== ad);
os.apiWithDialog('admin/ad/delete', {
- id: ad.id
+ id: ad.id,
});
});
}
@@ -90,28 +94,28 @@ function save(ad) {
if (ad.id == null) {
os.apiWithDialog('admin/ad/create', {
...ad,
- expiresAt: new Date(ad.expiresAt).getTime()
+ expiresAt: new Date(ad.expiresAt).getTime(),
});
} else {
os.apiWithDialog('admin/ad/update', {
...ad,
- expiresAt: new Date(ad.expiresAt).getTime()
+ expiresAt: new Date(ad.expiresAt).getTime(),
});
}
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.ads,
- icon: 'fas fa-audio-description',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.add,
- handler: add,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.add,
+ handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.ads,
+ icon: 'fas fa-audio-description',
});
</script>
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
index 97774975de..5107c2f302 100644
--- a/packages/client/src/pages/admin/announcements.vue
+++ b/packages/client/src/pages/admin/announcements.vue
@@ -1,34 +1,40 @@
<template>
-<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="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
- <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
- </div>
+<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="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
</div>
- </section>
-</div>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let announcements: any[] = $ref([]);
@@ -41,7 +47,7 @@ function add() {
id: null,
title: '',
text: '',
- imageUrl: null
+ imageUrl: null,
});
}
@@ -61,41 +67,41 @@ function save(announcement) {
os.api('admin/announcements/create', announcement).then(() => {
os.alert({
type: 'success',
- text: i18n.ts.saved
+ text: i18n.ts.saved,
});
}).catch(err => {
os.alert({
type: 'error',
- text: err
+ text: err,
});
});
} else {
os.api('admin/announcements/update', announcement).then(() => {
os.alert({
type: 'success',
- text: i18n.ts.saved
+ text: i18n.ts.saved,
});
}).catch(err => {
os.alert({
type: 'error',
- text: err
+ text: err,
});
});
}
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.announcements,
- icon: 'fas fa-broadcast-tower',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.add,
- handler: add,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.add,
+ handler: add,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.announcements,
+ icon: 'fas fa-broadcast-tower',
});
</script>
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
index 30fee5015a..d316f973bc 100644
--- a/packages/client/src/pages/admin/bot-protection.vue
+++ b/packages/client/src/pages/admin/bot-protection.vue
@@ -51,7 +51,6 @@ import FormButton from '@/components/ui/button.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSlot from '@/components/form/slot.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
const MkCaptcha = defineAsyncComponent(() => import('@/components/captcha.vue'));
@@ -62,27 +61,22 @@ let hcaptchaSecretKey: string | null = $ref(null);
let recaptchaSiteKey: string | null = $ref(null);
let recaptchaSecretKey: string | null = $ref(null);
-const enableHcaptcha = $computed(() => provider === 'hcaptcha');
-const enableRecaptcha = $computed(() => provider === 'recaptcha');
-
async function init() {
const meta = await os.api('admin/meta');
- enableHcaptcha = meta.enableHcaptcha;
hcaptchaSiteKey = meta.hcaptchaSiteKey;
hcaptchaSecretKey = meta.hcaptchaSecretKey;
- enableRecaptcha = meta.enableRecaptcha;
recaptchaSiteKey = meta.recaptchaSiteKey;
recaptchaSecretKey = meta.recaptchaSecretKey;
- provider = enableHcaptcha ? 'hcaptcha' : enableRecaptcha ? 'recaptcha' : null;
+ provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : null;
}
function save() {
os.apiWithDialog('admin/update-meta', {
- enableHcaptcha,
+ enableHcaptcha: provider === 'hcaptcha',
hcaptchaSiteKey,
hcaptchaSecretKey,
- enableRecaptcha,
+ enableRecaptcha: provider === 'recaptcha',
recaptchaSiteKey,
recaptchaSecretKey,
}).then(() => {
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
index d3519922b1..ca8718ef63 100644
--- a/packages/client/src/pages/admin/database.vue
+++ b/packages/client/src/pages/admin/database.vue
@@ -1,12 +1,13 @@
-<template>
-<MkSpacer :content-max="800" :margin-min="16" :margin-max="32">
+<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>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -14,18 +15,19 @@ import { } from 'vue';
import FormSuspense from '@/components/form/suspense.vue';
import MkKeyValue from '@/components/key-value.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
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));
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.database,
- icon: 'fas fa-database',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.database,
+ icon: 'fas fa-database',
});
</script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
index aa13043193..46cfd3db72 100644
--- a/packages/client/src/pages/admin/email-settings.vue
+++ b/packages/client/src/pages/admin/email-settings.vue
@@ -1,49 +1,53 @@
<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 }}</template>
- <template #caption>{{ i18n.ts.emailConfigInfo }}</template>
- </FormSwitch>
+<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>
+ <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>
+ <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/ui/info.vue';
@@ -51,9 +55,9 @@ 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 * as symbols from '@/symbols';
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);
@@ -78,13 +82,13 @@ async function testEmail() {
const { canceled, result: destination } = await os.inputText({
title: i18n.ts.destination,
type: 'email',
- placeholder: instance.maintainerEmail
+ placeholder: instance.maintainerEmail,
});
if (canceled) return;
os.apiWithDialog('admin/send-email', {
to: destination,
subject: 'Test email',
- text: 'Yo'
+ text: 'Yo',
});
}
@@ -102,21 +106,21 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.emailServer,
- icon: 'fas fa-envelope',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- text: i18n.ts.testEmail,
- handler: testEmail,
- }, {
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ text: i18n.ts.testEmail,
+ handler: testEmail,
+}, {
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.emailServer,
+ icon: 'fas fa-envelope',
});
</script>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
index 8ca5b3d65c..5ed2b14789 100644
--- a/packages/client/src/pages/admin/emojis.vue
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -1,69 +1,75 @@
<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="fas fa-search"></i></template>
- <template #label>{{ $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>{{ $ts.noCustomEmojis }}</span></template>
- <template v-slot="{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>
+ <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="fas fa-search"></i></template>
+ <template #label>{{ $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>
- </template>
- </MkPagination>
- </div>
+ <MkPagination ref="emojisPaginationComponent" :pagination="pagination">
+ <template #empty><span>{{ $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="fas fa-search"></i></template>
- <template #label>{{ $ts.search }}</template>
- </MkInput>
- <MkInput v-model="host" :debounce="true">
- <template #label>{{ $ts.host }}</template>
- </MkInput>
- </FormSplit>
- <MkPagination :pagination="remotePagination">
- <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
- <template v-slot="{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 v-else-if="tab === 'remote'" class="remote">
+ <FormSplit>
+ <MkInput v-model="queryRemote" :debounce="true" type="search">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkInput v-model="host" :debounce="true">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ </FormSplit>
+ <MkPagination :pagination="remotePagination">
+ <template #empty><span>{{ $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>
- </div>
- </div>
- </template>
- </MkPagination>
- </div>
- </div>
-</MkSpacer>
+ </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/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkPagination from '@/components/ui/pagination.vue';
@@ -72,8 +78,8 @@ 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 * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>();
@@ -131,13 +137,13 @@ const add = async (ev: MouseEvent) => {
const edit = (emoji) => {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
- emoji: emoji
+ emoji: emoji,
}, {
done: result => {
if (result.updated) {
emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
...oldEmoji,
- ...result.updated
+ ...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
@@ -159,7 +165,7 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
}, {
text: i18n.ts.import,
icon: 'fas fa-plus',
- action: () => { im(emoji); }
+ action: () => { im(emoji); },
}], ev.currentTarget ?? ev.target);
};
@@ -181,7 +187,7 @@ const menu = (ev: MouseEvent) => {
text: err.message,
});
});
- }
+ },
}, {
icon: 'fas fa-upload',
text: i18n.ts.import,
@@ -201,7 +207,7 @@ const menu = (ev: MouseEvent) => {
text: err.message,
});
});
- }
+ },
}], ev.currentTarget ?? ev.target);
};
@@ -265,31 +271,28 @@ const delBulk = async () => {
emojisPaginationComponent.value.reload();
};
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.customEmojis,
- icon: 'fas fa-laugh',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.addEmoji,
- handler: add,
- }, {
- icon: 'fas fa-ellipsis-h',
- handler: menu,
- }],
- tabs: [{
- active: tab.value === 'local',
- title: i18n.ts.local,
- onClick: () => { tab.value = 'local'; },
- }, {
- active: tab.value === 'remote',
- title: i18n.ts.remote,
- onClick: () => { tab.value = 'remote'; },
- },]
- })),
-});
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.addEmoji,
+ handler: add,
+}, {
+ icon: 'fas fa-ellipsis-h',
+ handler: menu,
+}]);
+
+const headerTabs = $computed(() => [{
+ key: 'local',
+ title: i18n.ts.local,
+}, {
+ key: 'remote',
+ title: i18n.ts.remote,
+}]);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.customEmojis,
+ icon: 'fas fa-laugh',
+})));
</script>
<style lang="scss" scoped>
diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue
deleted file mode 100644
index 0765548aab..0000000000
--- a/packages/client/src/pages/admin/file-dialog.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<template>
-<XModalWindow ref="dialog"
- :width="370"
- @close="$refs.dialog.close()"
- @closed="$emit('closed')"
->
- <template v-if="file" #header>{{ file.name }}</template>
- <div v-if="file" class="cxqhhsmd">
- <div class="_section">
- <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
- <div class="info">
- <span style="margin-right: 1em;">{{ file.type }}</span>
- <span>{{ bytes(file.size) }}</span>
- <MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
- </div>
- </div>
- <div class="_section">
- <div class="_content">
- <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
- </div>
- </div>
- <div class="_section">
- <div class="_content">
- <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
- <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
- </div>
- </div>
- <div v-if="info" class="_section">
- <details class="_content rawdata">
- <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
- </details>
- </div>
- </div>
-</XModalWindow>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import MkButton from '@/components/ui/button.vue';
-import MkSwitch from '@/components/form/switch.vue';
-import XModalWindow from '@/components/ui/modal-window.vue';
-import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
-import bytes from '@/filters/bytes';
-import * as os from '@/os';
-import { i18n } from '@/i18n';
-
-let file: any = $ref(null);
-let info: any = $ref(null);
-let isSensitive: boolean = $ref(false);
-
-const props = defineProps<{
- fileId: string,
-}>();
-
-async function fetch() {
- file = await os.api('drive/files/show', { fileId: props.fileId });
- info = await os.api('admin/drive/show-file', { fileId: props.fileId });
- isSensitive = file.isSensitive;
-}
-
-fetch();
-
-function showUser() {
- os.pageWindow(`/user-info/${file.userId}`);
-}
-
-async function del() {
- const { canceled } = await os.confirm({
- type: 'warning',
- text: i18n.t('removeAreYouSure', { x: file.name }),
- });
- if (canceled) return;
-
- os.apiWithDialog('drive/files/delete', {
- fileId: file.id
- });
-}
-
-async function toggleIsSensitive(v) {
- await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v });
- isSensitive = v;
-}
-</script>
-
-<style lang="scss" scoped>
-.cxqhhsmd {
- > ._section {
- > .thumbnail {
- height: 150px;
- max-width: 100%;
- }
-
- > .info {
- text-align: center;
- margin-top: 8px;
- }
-
- > .rawdata {
- overflow: auto;
- }
- }
-}
-</style>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
index 3cda688698..dd309180a7 100644
--- a/packages/client/src/pages/admin/files.vue
+++ b/packages/client/src/pages/admin/files.vue
@@ -1,81 +1,61 @@
<template>
-<div class="xrmjdkdw">
- <MkContainer :foldable="true" class="lookup">
- <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template>
- <div class="xrmjdkdw-lookup">
- <MkInput v-model="q" class="item" type="text" @enter="find()">
- <template #label>{{ $ts.fileIdOrUrl }}</template>
- </MkInput>
- <MkButton primary @click="find()"><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
- </div>
- </MkContainer>
-
- <div class="_section">
- <div class="_content">
- <div class="inputs" style="display: flex;">
- <MkSelect v-model="origin" style="margin: 0; flex: 1;">
- <template #label>{{ $ts.instance }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
- <template #label>{{ $ts.host }}</template>
- </MkInput>
- </div>
- <div class="inputs" style="display: flex; padding-top: 1.2em;">
- <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
- <template #label>MIME type</template>
- </MkInput>
- </div>
- <MkPagination v-slot="{items}" :pagination="pagination" class="urempief">
- <button v-for="file in items" :key="file.id" class="file _panel _button _gap" @click="show(file, $event)">
- <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
- <div class="body">
- <div>
- <small style="opacity: 0.7;">{{ file.name }}</small>
- </div>
- <div>
- <MkAcct v-if="file.user" :user="file.user"/>
- <div v-else>{{ $ts.system }}</div>
- </div>
- <div>
- <span style="margin-right: 1em;">{{ file.type }}</span>
- <span>{{ bytes(file.size) }}</span>
- </div>
- <div>
- <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
- </div>
+<div>
+ <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>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
</div>
- </button>
- </MkPagination>
- </div>
- </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/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
-import MkPagination from '@/components/ui/pagination.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import MkFileListForAdmin from '@/components/file-list-for-admin.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-let q = $ref(null);
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,
})),
@@ -93,83 +73,48 @@ function clear() {
}
function show(file) {
- os.popup(defineAsyncComponent(() => import('./file-dialog.vue')), {
- fileId: file.id
- }, {}, 'closed');
+ os.pageWindow(`/admin/file/${file.id}`);
}
-function find() {
+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
+ text: i18n.ts.notFound,
});
}
});
}
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.files,
- icon: 'fas fa-cloud',
- bg: 'var(--bg)',
- actions: [{
- text: i18n.ts.clearCachedFiles,
- icon: 'fas fa-trash-alt',
- handler: clear,
- }],
- })),
-});
+const headerActions = $computed(() => [{
+ text: i18n.ts.lookup,
+ icon: 'fas fa-search',
+ handler: find,
+}, {
+ text: i18n.ts.clearCachedFiles,
+ icon: 'fas fa-trash-alt',
+ handler: clear,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.files,
+ icon: 'fas fa-cloud',
+})));
</script>
<style lang="scss" scoped>
.xrmjdkdw {
margin: var(--margin);
-
- > .lookup {
- margin-bottom: 16px;
- }
-
- .urempief {
- margin-top: var(--margin);
-
- > .file {
- display: flex;
- width: 100%;
- box-sizing: border-box;
- text-align: left;
- align-items: center;
-
- &:hover {
- color: var(--accent);
- }
-
- > .thumbnail {
- width: 128px;
- height: 128px;
- }
-
- > .body {
- margin-left: 0.3em;
- padding: 8px;
- flex: 1;
-
- @media (max-width: 500px) {
- font-size: 14px;
- }
- }
- }
- }
-}
-
-.xrmjdkdw-lookup {
- padding: 16px;
-
- > .item {
- margin-bottom: 16px;
- }
}
</style>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
index 9b7fa5678e..f0ac5b3fc9 100644
--- a/packages/client/src/pages/admin/index.vue
+++ b/packages/client/src/pages/admin/index.vue
@@ -1,50 +1,46 @@
<template>
<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
- <div v-if="!narrow || initialPage == null" class="nav">
- <MkHeader :info="header"></MkHeader>
-
+ <div v-if="!narrow || initialPage == 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">{{ $ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ $ts.check }}</MkA></MkInfo>
<MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noEmailServer" warn class="info">{{ $ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
</div>
</MkSpacer>
</div>
<div v-if="!(narrow && initialPage == null)" class="main">
- <MkStickyContainer>
- <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
- <component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/>
- </MkStickyContainer>
+ <component :is="component" :key="initialPage" v-bind="pageProps"/>
</div>
</div>
</template>
<script lang="ts" setup>
-import { defineAsyncComponent, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
+import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue';
import { i18n } from '@/i18n';
import MkSuperMenu from '@/components/ui/super-menu.vue';
import MkInfo from '@/components/ui/info.vue';
import { scroll } from '@/scripts/scroll';
import { instance } from '@/instance';
-import * as symbols from '@/symbols';
import * as os from '@/os';
import { lookupUser } from '@/scripts/lookup-user';
-import { MisskeyNavigator } from '@/scripts/navigate';
+import { useRouter } from '@/router';
+import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
const isEmpty = (x: string | null) => x == null || x === '';
-const nav = new MisskeyNavigator();
+const router = useRouter();
const indexInfo = {
title: i18n.ts.controlPanel,
icon: 'fas fa-cog',
- bg: 'var(--bg)',
hideHeader: true,
};
@@ -63,6 +59,15 @@ let el = $ref(null);
let pageProps = $ref({});
let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail);
let noBotProtection = !instance.enableHcaptcha && !instance.enableRecaptcha;
+let noEmailServer = !instance.enableEmail;
+let thereIsUnresolvedAbuseReport = $ref(false);
+
+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) => {
@@ -103,7 +108,7 @@ const menuDef = $computed(() => [{
}, {
icon: 'fas fa-globe',
text: i18n.ts.federation,
- to: '/admin/federation',
+ to: '/about#federation',
active: props.initialPage === 'federation',
}, {
icon: 'fas fa-clipboard-list',
@@ -195,7 +200,7 @@ const component = $computed(() => {
case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
case 'users': return defineAsyncComponent(() => import('./users.vue'));
case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
- case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
+ //case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
case 'files': return defineAsyncComponent(() => import('./files.vue'));
case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
@@ -224,7 +229,7 @@ watch(component, () => {
watch(() => props.initialPage, () => {
if (props.initialPage == null && !narrow) {
- nav.push('/admin/overview');
+ router.push('/admin/overview');
} else {
if (props.initialPage == null) {
INFO = indexInfo;
@@ -234,7 +239,7 @@ watch(() => props.initialPage, () => {
watch(narrow, () => {
if (props.initialPage == null && !narrow) {
- nav.push('/admin/overview');
+ router.push('/admin/overview');
}
});
@@ -243,7 +248,7 @@ onMounted(() => {
narrow = el.offsetWidth < NARROW_THRESHOLD;
if (props.initialPage == null && !narrow) {
- nav.push('/admin/overview');
+ router.push('/admin/overview');
}
});
@@ -251,19 +256,19 @@ onUnmounted(() => {
ro.disconnect();
});
-const pageChanged = (page) => {
- if (page == null) {
+provideMetadataReceiver((info) => {
+ if (info == null) {
childInfo = null;
} else {
- childInfo = page[symbols.PAGE_INFO];
+ childInfo = info;
}
-};
+});
const invite = () => {
os.api('admin/invite').then(x => {
os.alert({
type: 'info',
- text: x.code
+ text: x.code,
});
}).catch(err => {
os.alert({
@@ -279,33 +284,38 @@ const lookup = (ev) => {
icon: 'fas fa-user',
action: () => {
lookupUser();
- }
+ },
}, {
text: i18n.ts.note,
icon: 'fas fa-pencil-alt',
action: () => {
alert('TODO');
- }
+ },
}, {
text: i18n.ts.file,
icon: 'fas fa-cloud',
action: () => {
alert('TODO');
- }
+ },
}, {
text: i18n.ts.instance,
icon: 'fas fa-globe',
action: () => {
alert('TODO');
- }
+ },
}], ev.currentTarget ?? ev.target);
};
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(INFO);
+
defineExpose({
- [symbols.PAGE_INFO]: INFO,
header: {
title: i18n.ts.controlPanel,
- }
+ },
});
</script>
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
index 3347846a80..6d479e8f0d 100644
--- a/packages/client/src/pages/admin/instance-block.vue
+++ b/packages/client/src/pages/admin/instance-block.vue
@@ -1,25 +1,29 @@
<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>
+<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="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
- </FormSuspense>
-</MkSpacer>
+ <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></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/ui/button.vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let blockedHosts: string = $ref('');
@@ -36,11 +40,12 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.instanceBlocking,
- icon: 'fas fa-ban',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.instanceBlocking,
+ icon: 'fas fa-ban',
});
</script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
index d6061d0e51..9964426a68 100644
--- a/packages/client/src/pages/admin/integrations.vue
+++ b/packages/client/src/pages/admin/integrations.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<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="fab fa-twitter"></i></template>
@@ -20,19 +21,19 @@
<XDiscord/>
</FormFolder>
</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
-import FormFolder from '@/components/form/folder.vue';
-import FormSuspense from '@/components/form/suspense.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 * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let enableTwitterIntegration: boolean = $ref(false);
let enableGithubIntegration: boolean = $ref(false);
@@ -45,11 +46,12 @@ async function init() {
enableDiscordIntegration = meta.enableDiscordIntegration;
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.integration,
- icon: 'fas fa-share-alt',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.integration,
+ icon: 'fas fa-share-alt',
});
</script>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
index d109db9c38..5cc3018532 100644
--- a/packages/client/src/pages/admin/object-storage.vue
+++ b/packages/client/src/pages/admin/object-storage.vue
@@ -1,82 +1,85 @@
<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>
+<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>
+ <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="objectStorageEndpoint" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
- <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
- </FormInput>
+ <FormInput v-model="objectStorageBucket" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageBucket }}</template>
+ <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
+ </FormInput>
- <FormInput v-model="objectStorageRegion" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageRegion }}</template>
- <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
- </FormInput>
+ <FormInput v-model="objectStoragePrefix" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStoragePrefix }}</template>
+ <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
+ </FormInput>
- <FormSplit :min-width="280">
- <FormInput v-model="objectStorageAccessKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>Access key</template>
+ <FormInput v-model="objectStorageEndpoint" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
+ <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
</FormInput>
- <FormInput v-model="objectStorageSecretKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>Secret key</template>
+ <FormInput v-model="objectStorageRegion" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageRegion }}</template>
+ <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
</FormInput>
- </FormSplit>
- <FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
- <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
- </FormSwitch>
+ <FormSplit :min-width="280">
+ <FormInput v-model="objectStorageAccessKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Access key</template>
+ </FormInput>
- <FormSwitch v-model="objectStorageUseProxy" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
- <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
- </FormSwitch>
+ <FormInput v-model="objectStorageSecretKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Secret key</template>
+ </FormInput>
+ </FormSplit>
- <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock">
- <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
- </FormSwitch>
+ <FormSwitch v-model="objectStorageUseSSL" class="_formBlock">
+ <template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
+ <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
+ </FormSwitch>
- <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock">
- <template #label>s3ForcePathStyle</template>
- </FormSwitch>
- </template>
- </div>
- </FormSuspense>
-</MkSpacer>
+ <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 FormGroup from '@/components/form/group.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 * as symbols from '@/symbols';
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);
@@ -129,17 +132,17 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.objectStorage,
- icon: 'fas fa-cloud',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.objectStorage,
+ icon: 'fas fa-cloud',
});
</script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
index 552b05f347..ee4e8edba0 100644
--- a/packages/client/src/pages/admin/other-settings.vue
+++ b/packages/client/src/pages/admin/other-settings.vue
@@ -1,18 +1,22 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- none
- </FormSuspense>
-</MkSpacer>
+<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 * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
async function init() {
await os.api('admin/meta');
@@ -24,17 +28,17 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.other,
- icon: 'fas fa-cogs',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.other,
+ icon: 'fas fa-cogs',
});
</script>
diff --git a/packages/client/src/pages/admin/overview.federation.vue b/packages/client/src/pages/admin/overview.federation.vue
new file mode 100644
index 0000000000..6c99cad33c
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.federation.vue
@@ -0,0 +1,100 @@
+<template>
+<div class="wbrkwale">
+ <MkLoading v-if="fetching"/>
+ <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
+ <MkA v-for="(instance, i) in instances" :key="instance.id" :to="`/instance-info/${instance.host}`" class="instance">
+ <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/>
+ <div class="body">
+ <div class="name">{{ instance.name ?? instance.host }}</div>
+ <div class="host">{{ instance.host }}</div>
+ </div>
+ <MkMiniChart class="chart" :src="charts[i].requests.received"/>
+ </MkA>
+ </transition-group>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import MkMiniChart from '@/components/mini-chart.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+
+const instances = ref([]);
+const charts = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+ const fetchedInstances = await os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 5,
+ });
+ const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
+ instances.value = fetchedInstances;
+ charts.value = fetchedCharts;
+ fetching.value = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+ immediate: true,
+ afterMounted: true,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwale {
+ > .instances {
+ .chart-move {
+ transition: transform 1s ease;
+ }
+
+ > .instance {
+ display: flex;
+ align-items: center;
+ padding: 16px 20px;
+
+ &:not(:last-child) {
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > img {
+ display: block;
+ width: 34px;
+ height: 34px;
+ object-fit: cover;
+ border-radius: 4px;
+ margin-right: 12px;
+ }
+
+ > .body {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+ padding-right: 8px;
+
+ > .name {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .host {
+ margin: 0;
+ font-size: 75%;
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .chart {
+ height: 30px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/overview.pie.vue b/packages/client/src/pages/admin/overview.pie.vue
new file mode 100644
index 0000000000..d3b2032876
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.pie.vue
@@ -0,0 +1,108 @@
+<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();
+
+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/client/src/pages/admin/overview.queue-chart.vue b/packages/client/src/pages/admin/overview.queue-chart.vue
new file mode 100644
index 0000000000..a2b748ad38
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.queue-chart.vue
@@ -0,0 +1,211 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts" setup>
+import { defineComponent, 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';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const props = defineProps<{
+ domain: string;
+ connection: any;
+}>();
+
+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;
+
+const onStats = (stats) => {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 100) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ chartInstance.update();
+};
+
+const onStatsLog = (statsLog) => {
+ for (const stats of [...statsLog].reverse()) {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 100) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ }
+ chartInstance.update();
+};
+
+onMounted(() => {
+ chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Process',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00E396',
+ backgroundColor: alpha('#00E396', 0.1),
+ data: [],
+ }, {
+ label: 'Active',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00BCD4',
+ backgroundColor: alpha('#00BCD4', 0.1),
+ data: [],
+ }, {
+ label: 'Waiting',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#FFB300',
+ backgroundColor: alpha('#FFB300', 0.1),
+ data: [],
+ }, {
+ label: 'Delayed',
+ pointRadius: 0,
+ tension: 0.3,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#E53935',
+ borderDash: [5, 5],
+ fill: false,
+ data: [],
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ display: false,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ maxTicksLimit: 10,
+ },
+ },
+ y: {
+ display: false,
+ min: 0,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ external: externalTooltipHandler,
+ },
+ },
+ },
+ });
+
+ props.connection.on('stats', onStats);
+ props.connection.on('statsLog', onStatsLog);
+});
+
+onUnmounted(() => {
+ props.connection.off('stats', onStats);
+ props.connection.off('statsLog', onStatsLog);
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/overview.user.vue b/packages/client/src/pages/admin/overview.user.vue
new file mode 100644
index 0000000000..d70336f3c2
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.user.vue
@@ -0,0 +1,76 @@
+<template>
+<MkA :class="[$style.root]" :to="`/user-info/${user.id}`">
+ <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+ <div class="body">
+ <span class="name"><MkUserName class="name" :user="user"/></span>
+ <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
+ </div>
+ <MkMiniChart v-if="chart" class="chart" :src="chart.inc"/>
+</MkA>
+</template>
+
+<script lang="ts" setup>
+import * as misskey from 'misskey-js';
+import MkMiniChart from '@/components/mini-chart.vue';
+import * as os from '@/os';
+import { acct } from '@/filters/user';
+
+const props = defineProps<{
+ user: misskey.entities.User;
+}>();
+
+let chart = $ref(null);
+
+os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16, span: 'day' }).then(res => {
+ chart = res;
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ $bodyTitleHieght: 18px;
+ $bodyInfoHieght: 16px;
+
+ display: flex;
+ align-items: center;
+
+ > :global(.avatar) {
+ display: block;
+ width: ($bodyTitleHieght + $bodyInfoHieght);
+ height: ($bodyTitleHieght + $bodyInfoHieght);
+ margin-right: 12px;
+ }
+
+ > :global(.body) {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+ padding-right: 8px;
+
+ > :global(.name) {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: $bodyTitleHieght;
+ }
+
+ > :global(.sub) {
+ display: block;
+ width: 100%;
+ font-size: 95%;
+ opacity: 0.7;
+ line-height: $bodyInfoHieght;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > :global(.chart) {
+ height: 30px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
index cc69424c3b..7e085106b9 100644
--- a/packages/client/src/pages/admin/overview.vue
+++ b/packages/client/src/pages/admin/overview.vue
@@ -1,112 +1,458 @@
<template>
-<div v-size="{ max: [740] }" class="edbbcaef">
- <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)">
- <div class="number _panel">
- <div class="label">Users</div>
- <div class="value _monospace">
- {{ number(stats.originalUsersCount) }}
- <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+<MkSpacer :content-max="900">
+ <div ref="rootEl" v-size="{ max: [740] }" class="edbbcaef">
+ <div class="left">
+ <div v-if="stats" class="container stats">
+ <div class="title">Stats</div>
+ <div class="body">
+ <div class="number _panel">
+ <div class="label">Users</div>
+ <div class="value _monospace">
+ {{ number(stats.originalUsersCount) }}
+ <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Notes</div>
+ <div class="value _monospace">
+ {{ number(stats.originalNotesCount) }}
+ <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ </div>
</div>
- </div>
- <div class="number _panel">
- <div class="label">Notes</div>
- <div class="value _monospace">
- {{ number(stats.originalNotesCount) }}
- <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff>
- </div>
- </div>
- </div>
- <MkContainer :foldable="true" class="charts">
- <template #header><i class="fas fa-chart-bar"></i>{{ i18n.ts.charts }}</template>
- <div style="padding: 12px;">
- <MkInstanceStats :chart-limit="500" :detailed="true"/>
- </div>
- </MkContainer>
+ <div class="container queue">
+ <div class="title">Job queue</div>
+ <div class="body">
+ <div class="chart deliver">
+ <div class="title">Deliver</div>
+ <XQueueChart :connection="queueStatsConnection" domain="deliver"/>
+ </div>
+ <div class="chart inbox">
+ <div class="title">Inbox</div>
+ <XQueueChart :connection="queueStatsConnection" domain="inbox"/>
+ </div>
+ </div>
+ </div>
- <div class="queue">
- <MkContainer :foldable="true" :thin="true" class="deliver">
- <template #header>Queue: deliver</template>
- <MkQueueChart :connection="queueStatsConnection" domain="deliver"/>
- </MkContainer>
- <MkContainer :foldable="true" :thin="true" class="inbox">
- <template #header>Queue: inbox</template>
- <MkQueueChart :connection="queueStatsConnection" domain="inbox"/>
- </MkContainer>
- </div>
+ <div class="container users">
+ <div class="title">New users</div>
+ <div v-if="newUsers" class="body">
+ <XUser v-for="user in newUsers" :key="user.id" class="user" :user="user"/>
+ </div>
+ </div>
- <!--<XMetrics/>-->
+ <div class="container files">
+ <div class="title">Recent files</div>
+ <div class="body">
+ <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/>
+ </div>
+ </div>
- <MkFolder style="margin: var(--margin)">
- <template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template>
- <div class="cfcdecdf">
- <div class="number _panel">
- <div class="label">Misskey</div>
- <div class="value _monospace">{{ version }}</div>
+ <div class="container env">
+ <div class="title">Enviroment</div>
+ <div class="body">
+ <div class="number _panel">
+ <div class="label">Misskey</div>
+ <div class="value _monospace">{{ version }}</div>
+ </div>
+ <div v-if="serverInfo" class="number _panel">
+ <div class="label">Node.js</div>
+ <div class="value _monospace">{{ serverInfo.node }}</div>
+ </div>
+ <div v-if="serverInfo" class="number _panel">
+ <div class="label">PostgreSQL</div>
+ <div class="value _monospace">{{ serverInfo.psql }}</div>
+ </div>
+ <div v-if="serverInfo" class="number _panel">
+ <div class="label">Redis</div>
+ <div class="value _monospace">{{ serverInfo.redis }}</div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Vue</div>
+ <div class="value _monospace">{{ vueVersion }}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="right">
+ <div class="container charts">
+ <div class="title">Active users</div>
+ <div class="body">
+ <canvas ref="chartEl"></canvas>
+ </div>
</div>
- <div v-if="serverInfo" class="number _panel">
- <div class="label">Node.js</div>
- <div class="value _monospace">{{ serverInfo.node }}</div>
+ <div class="container federation">
+ <div class="title">Active instances</div>
+ <div class="body">
+ <XFederation/>
+ </div>
</div>
- <div v-if="serverInfo" class="number _panel">
- <div class="label">PostgreSQL</div>
- <div class="value _monospace">{{ serverInfo.psql }}</div>
+ <div v-if="stats" class="container federationStats">
+ <div class="title">Federation</div>
+ <div class="body">
+ <div class="number _panel">
+ <div class="label">Sub</div>
+ <div class="value _monospace">
+ {{ number(federationSubActive) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Pub</div>
+ <div class="value _monospace">
+ {{ number(federationPubActive) }}
+ <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ </div>
</div>
- <div v-if="serverInfo" class="number _panel">
- <div class="label">Redis</div>
- <div class="value _monospace">{{ serverInfo.redis }}</div>
+ <div class="container tagCloud">
+ <div class="body">
+ <MkTagCloud v-if="activeInstances">
+ <li v-for="instance in activeInstances">
+ <a @click.prevent="onInstanceClick(instance)">
+ <img style="width: 32px;" :src="instance.iconUrl">
+ </a>
+ </li>
+ </MkTagCloud>
+ </div>
</div>
- <div class="number _panel">
- <div class="label">Vue</div>
- <div class="value _monospace">{{ vueVersion }}</div>
+ <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="container federationPies">
+ <div class="body">
+ <div class="chart deliver">
+ <div class="title">Sub</div>
+ <XPie :data="topSubInstancesForPie"/>
+ <div class="subTitle">Top 10</div>
+ </div>
+ <div class="chart inbox">
+ <div class="title">Pub</div>
+ <XPie :data="topPubInstancesForPie"/>
+ <div class="subTitle">Top 10</div>
+ </div>
+ </div>
</div>
</div>
- </MkFolder>
-</div>
+ </div>
+</MkSpacer>
</template>
<script lang="ts" setup>
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
-import MkInstanceStats from '@/components/instance-stats.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 MagicGrid from 'magic-grid';
+import XMetrics from './metrics.vue';
+import XFederation from './overview.federation.vue';
+import XQueueChart from './overview.queue-chart.vue';
+import XUser from './overview.user.vue';
+import XPie from './overview.pie.vue';
import MkNumberDiff from '@/components/number-diff.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkFolder from '@/components/ui/folder.vue';
-import MkQueueChart from '@/components/queue-chart.vue';
+import MkTagCloud from '@/components/tag-cloud.vue';
import { version, url } from '@/config';
import number from '@/filters/number';
-import XMetrics from './metrics.vue';
import * as os from '@/os';
import { stream } from '@/stream';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import 'chartjs-adapter-date-fns';
+import { defaultStore } from '@/store';
+import { useChartTooltip } from '@/scripts/use-chart-tooltip';
+import MkFileListForAdmin from '@/components/file-list-for-admin.vue';
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ //gradient,
+);
+
+const rootEl = $ref<HTMLElement>();
+const chartEl = $ref<HTMLCanvasElement>(null);
let stats: any = $ref(null);
let serverInfo: any = $ref(null);
+let topSubInstancesForPie: any = $ref(null);
+let topPubInstancesForPie: any = $ref(null);
let usersComparedToThePrevDay: any = $ref(null);
let notesComparedToThePrevDay: 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();
+let chartInstance: Chart = null;
+const chartLimit = 30;
+const filesPagination = {
+ endpoint: 'admin/drive/files' as const,
+ limit: 9,
+ noPaging: 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 color = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent'));
+
+ chartInstance = new Chart(chartEl, {
+ type: 'bar',
+ data: {
+ //labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
+ datasets: [{
+ parsing: false,
+ label: 'a',
+ data: format(raw.readWrite).slice().reverse(),
+ tension: 0.3,
+ pointRadius: 0,
+ borderWidth: 0,
+ borderJoinStyle: 'round',
+ borderRadius: 3,
+ backgroundColor: color,
+ /*gradient: props.bar ? undefined : {
+ backgroundColor: {
+ axis: 'y',
+ colors: {
+ 0: alpha(x.color ? x.color : getColor(i), 0),
+ [maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2),
+ },
+ },
+ },*/
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ clip: 8,
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ display: false,
+ stacked: true,
+ offset: false,
+ time: {
+ stepSize: 1,
+ unit: 'month',
+ },
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ },
+ adapters: {
+ date: {
+ locale: enUS,
+ },
+ },
+ min: getDate(chartLimit).getTime(),
+ },
+ y: {
+ display: false,
+ position: 'left',
+ stacked: true,
+ grid: {
+ display: false,
+ },
+ ticks: {
+ display: false,
+ //mirror: true,
+ },
+ },
+ },
+ 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: [{
+ id: 'vLine',
+ beforeDraw(chart, args, options) {
+ if (chart.tooltip._active && chart.tooltip._active.length) {
+ const activePoint = chart.tooltip._active[0];
+ const ctx = chart.ctx;
+ const x = activePoint.element.x;
+ const topY = chart.scales.y.top;
+ const bottomY = chart.scales.y.bottom;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(x, bottomY);
+ ctx.lineTo(x, topY);
+ ctx.lineWidth = 1;
+ ctx.strokeStyle = vLineColor;
+ ctx.stroke();
+ ctx.restore();
+ }
+ },
+ }],
+ });
+}
+
+function onInstanceClick(i) {
+ os.pageWindow(`/instance-info/${i.host}`);
+}
+
+onMounted(async () => {
+ /*
+ const magicGrid = new MagicGrid({
+ container: rootEl,
+ static: true,
+ animate: true,
+ });
+
+ magicGrid.listen();
+ */
+
+ renderChart();
-onMounted(async () => {
os.api('stats', {}).then(statsResponse => {
stats = statsResponse;
- os.api('charts/users', { limit: 2, span: 'day' }).then(chart => {
+ os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => {
usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1];
});
- os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+ os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => {
notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1];
});
});
+ 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: 200
+ length: 100,
});
});
});
@@ -115,74 +461,177 @@ onBeforeUnmount(() => {
queueStatsConnection.dispose();
});
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.dashboard,
- icon: 'fas fa-tachometer-alt',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.dashboard,
+ icon: 'fas fa-tachometer-alt',
});
</script>
<style lang="scss" scoped>
.edbbcaef {
- .cfcdecdf {
- display: grid;
- grid-gap: 8px;
- grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
+ display: flex;
- > .number {
- padding: 12px 16px;
+ > .left, > .right {
+ box-sizing: border-box;
+ width: 50%;
- > .label {
- opacity: 0.7;
- font-size: 0.8em;
- }
+ > .container {
+ margin: 32px 0;
- > .value {
+ > .title {
font-weight: bold;
- font-size: 1.2em;
+ margin-bottom: 16px;
+ }
+
+ &.stats, &.federationStats {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+
+ > .number {
+ padding: 14px 20px;
+
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
+
+ > .value {
+ font-weight: bold;
+ font-size: 1.5em;
- > .diff {
- font-size: 0.8em;
+ > .diff {
+ font-size: 0.7em;
+ }
+ }
+ }
}
}
- }
- }
- > .charts {
- margin: var(--margin);
- }
+ &.env {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
- > .queue {
- margin: var(--margin);
- display: flex;
+ > .number {
+ padding: 14px 20px;
- > .deliver,
- > .inbox {
- flex: 1;
- width: 50%;
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
- &:not(:first-child) {
- margin-left: var(--margin);
+ > .value {
+ font-size: 1.1em;
+ }
+ }
+ }
}
- }
- }
- &.max-width_740px {
- > .queue {
- display: block;
+ &.charts {
+ > .body {
+ padding: 32px;
+ background: var(--panel);
+ border-radius: var(--radius);
+ }
+ }
- > .deliver,
- > .inbox {
- width: 100%;
+ &.users {
+ > .body {
+ background: var(--panel);
+ border-radius: var(--radius);
- &:not(:first-child) {
- margin-top: var(--margin);
- margin-left: 0;
+ > .user {
+ padding: 16px 20px;
+
+ &:not(:last-child) {
+ border-bottom: solid 0.5px var(--divider);
+ }
+ }
+ }
+ }
+
+ &.federation {
+ > .body {
+ background: var(--panel);
+ border-radius: var(--radius);
+ overflow: hidden; overflow: clip;
+ }
+ }
+
+ &.queue {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+
+ > .chart {
+ position: relative;
+ padding: 20px;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ font-size: 90%;
+ }
+ }
+ }
+ }
+
+ &.federationPies {
+ > .body {
+ display: grid;
+ grid-gap: 16px;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+
+ > .chart {
+ position: relative;
+ padding: 20px;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ font-size: 90%;
+ }
+
+ > .subTitle {
+ position: absolute;
+ bottom: 20px;
+ right: 20px;
+ font-size: 85%;
+ }
+ }
+ }
+ }
+
+ &.tagCloud {
+ > .body {
+ background: var(--panel);
+ border-radius: var(--radius);
+ overflow: hidden; overflow: clip;
}
}
}
}
+
+ > .left {
+ padding-right: 16px;
+ }
+
+ > .right {
+ padding-left: 16px;
+ }
}
</style>
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
index 727e20e7e5..0951d26c24 100644
--- a/packages/client/src/pages/admin/proxy-account.vue
+++ b/packages/client/src/pages/admin/proxy-account.vue
@@ -1,5 +1,6 @@
-<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+<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">
@@ -9,7 +10,7 @@
<FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton>
</FormSuspense>
-</MkSpacer>
+</MkSpacer></MkStickyContainer>
</template>
<script lang="ts" setup>
@@ -19,9 +20,9 @@ import FormButton from '@/components/ui/button.vue';
import MkInfo from '@/components/ui/info.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let proxyAccount: any = $ref(null);
let proxyAccountId: any = $ref(null);
@@ -50,11 +51,12 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.proxyAccount,
- icon: 'fas fa-ghost',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.proxyAccount,
+ icon: 'fas fa-ghost',
});
</script>
diff --git a/packages/client/src/pages/admin/queue.chart.chart.vue b/packages/client/src/pages/admin/queue.chart.chart.vue
new file mode 100644
index 0000000000..96156f8e67
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.chart.chart.vue
@@ -0,0 +1,181 @@
+<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';
+
+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(() => {
+ 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.1),
+ 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,
+ },
+ },
+ },
+ });
+});
+
+defineExpose({
+ setData,
+ pushData,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue
index be63830bdd..c213037b65 100644
--- a/packages/client/src/pages/admin/queue.chart.vue
+++ b/packages/client/src/pages/admin/queue.chart.vue
@@ -1,80 +1,148 @@
<template>
-<div class="_debobigegoItem">
- <div class="_debobigegoLabel"><slot name="title"></slot></div>
- <div class="_debobigegoPanel pumxzjhg">
- <div class="_table status">
- <div class="_row">
- <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
- <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
- <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
- <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
- </div>
+<div 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="">
- <MkQueueChart :domain="domain" :connection="connection"/>
+ <div class="chart">
+ <div class="title">Active</div>
+ <XChart ref="chartActive" type="active"/>
</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 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>
- <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
</div>
+ <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
</div>
</div>
</template>
<script lang="ts" setup>
-import { onMounted, onUnmounted, ref } from 'vue';
+import { markRaw, onMounted, onUnmounted, ref } from 'vue';
+import XChart from './queue.chart.chart.vue';
import number from '@/filters/number';
-import MkQueueChart from '@/components/queue-chart.vue';
import * as os from '@/os';
+import { stream } from '@/stream';
+
+const connection = markRaw(stream.useChannel('queueStats'));
const activeSincePrevTick = ref(0);
const active = ref(0);
-const waiting = 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,
- connection: any,
+ 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;
});
- const onStats = (stats) => {
- activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
- active.value = stats[props.domain].active;
- waiting.value = stats[props.domain].waiting;
- delayed.value = stats[props.domain].delayed;
- };
-
- props.connection.on('stats', onStats);
-
- onUnmounted(() => {
- props.connection.off('stats', onStats);
+ 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;
- border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .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;
- border-top: solid 0.5px var(--divider);
max-height: 180px;
overflow: auto;
+ background: var(--panel);
+ border-radius: var(--radius);
}
+
}
</style>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
index 656b18199f..6ccb464d17 100644
--- a/packages/client/src/pages/admin/queue.vue
+++ b/packages/client/src/pages/admin/queue.vue
@@ -1,26 +1,24 @@
<template>
-<MkSpacer :content-max="800">
- <XQueue :connection="connection" domain="inbox">
- <template #title>In</template>
- </XQueue>
- <XQueue :connection="connection" domain="deliver">
- <template #title>Out</template>
- </XQueue>
- <MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton>
-</MkSpacer>
+<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 MkButton from '@/components/ui/button.vue';
import XQueue from './queue.chart.vue';
+import XHeader from './_header_.vue';
+import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import { stream } from '@/stream';
-import * as symbols from '@/symbols';
import * as config from '@/config';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
-const connection = markRaw(stream.useChannel('queueStats'));
+let tab = $ref('deliver');
function clear() {
os.confirm({
@@ -34,32 +32,25 @@ function clear() {
});
}
-onMounted(() => {
- nextTick(() => {
- connection.send('requestLog', {
- id: Math.random().toString().substr(2, 8),
- length: 200
- });
- });
-});
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-up-right-from-square',
+ text: i18n.ts.dashboard,
+ handler: () => {
+ window.open(config.url + '/queue', '_blank');
+ },
+}]);
-onBeforeUnmount(() => {
- connection.dispose();
-});
+const headerTabs = $computed(() => [{
+ key: 'deliver',
+ title: 'Deliver',
+}, {
+ key: 'inbox',
+ title: 'Inbox',
+}]);
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.jobQueue,
- icon: 'fas fa-clipboard-list',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-up-right-from-square',
- text: i18n.ts.dashboard,
- handler: () => {
- window.open(config.url + '/queue', '_blank');
- },
- }],
- }
+definePageMetadata({
+ title: i18n.ts.jobQueue,
+ icon: 'fas fa-clipboard-list',
});
</script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
index 1a36bb4753..42347c0e7d 100644
--- a/packages/client/src/pages/admin/relays.vue
+++ b/packages/client/src/pages/admin/relays.vue
@@ -1,24 +1,28 @@
<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="fas fa-check icon accepted"></i>
- <i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
- <i v-else class="fas fa-clock icon requesting"></i>
- <span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+<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="fas fa-check icon accepted"></i>
+ <i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i>
+ <i v-else class="fas fa-clock icon requesting"></i>
+ <span>{{ $t(`_relayStatus.${relay.status}`) }}</span>
+ </div>
+ <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
</div>
- <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton>
- </div>
-</MkSpacer>
+ </MkSpacer>
+</MkStickyContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
+import XHeader from './_header_.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
let relays: any[] = $ref([]);
@@ -26,30 +30,30 @@ async function addRelay() {
const { canceled, result: inbox } = await os.inputText({
title: i18n.ts.addRelay,
type: 'url',
- placeholder: i18n.ts.inboxUrl
+ placeholder: i18n.ts.inboxUrl,
});
if (canceled) return;
os.api('admin/relays/add', {
- inbox
+ inbox,
}).then((relay: any) => {
refresh();
}).catch((err: any) => {
os.alert({
type: 'error',
- text: err.message || err
+ text: err.message || err,
});
});
}
function remove(inbox: string) {
os.api('admin/relays/remove', {
- inbox
+ inbox,
}).then(() => {
refresh();
}).catch((err: any) => {
os.alert({
type: 'error',
- text: err.message || err
+ text: err.message || err,
});
});
}
@@ -62,18 +66,18 @@ function refresh() {
refresh();
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.relays,
- icon: 'fas fa-globe',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.addRelay,
- handler: addRelay,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.addRelay,
+ handler: addRelay,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.relays,
+ icon: 'fas fa-globe',
});
</script>
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
index 6b8f70cca5..c4a4994bb8 100644
--- a/packages/client/src/pages/admin/security.vue
+++ b/packages/client/src/pages/admin/security.vue
@@ -1,73 +1,160 @@
<template>
-<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
- <FormSuspense :p="init">
- <div class="_formRoot">
- <FormFolder class="_formBlock">
- <template #icon><i class="fas fa-shield-alt"></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 #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</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="fas fa-shield-alt"></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 #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
- <XBotProtection/>
- </FormFolder>
+ <XBotProtection/>
+ </FormFolder>
- <FormFolder class="_formBlock">
- <template #label>Summaly Proxy</template>
+ <FormFolder class="_formBlock">
+ <template #icon><i class="fas fa-eye-slash"></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">
- <FormInput v-model="summalyProxy" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>Summaly Proxy URL</template>
- </FormInput>
+ <div class="_formRoot">
+ <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span>
- <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
- </div>
- </FormFolder>
- </div>
- </FormSuspense>
-</MkSpacer>
+ <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="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
+ </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:modelValue="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="fas fa-link"></i></template>
+ <template #label>Summaly Proxy URL</template>
+ </FormInput>
+
+ <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></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/ui/info.vue';
import FormSuspense from '@/components/form/suspense.vue';
-import FormSection from '@/components/form/section.vue';
+import FormRange from '@/components/form/range.vue';
import FormInput from '@/components/form/input.vue';
import FormButton from '@/components/ui/button.vue';
-import XBotProtection from './bot-protection.vue';
import * as os from '@/os';
-import * as symbols from '@/symbols';
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 sensitiveMediaDetection: string = $ref('none');
+let sensitiveMediaDetectionSensitivity: number = $ref(0);
+let setSensitiveFlagAutomatically: boolean = $ref(false);
+let enableSensitiveMediaDetectionForVideos: boolean = $ref(false);
+let enableIpLogging: boolean = $ref(false);
async function init() {
const meta = await os.api('admin/meta');
summalyProxy = meta.summalyProxy;
enableHcaptcha = meta.enableHcaptcha;
enableRecaptcha = meta.enableRecaptcha;
+ 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;
}
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,
}).then(() => {
fetchInstance();
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.security,
- icon: 'fas fa-lock',
- bg: 'var(--bg)',
- }
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.security,
+ icon: 'fas fa-lock',
});
</script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
index 6dc30fe50b..496eb46ea4 100644
--- a/packages/client/src/pages/admin/settings.vue
+++ b/packages/client/src/pages/admin/settings.vue
@@ -1,149 +1,155 @@
<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>
+<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>
+ <FormTextarea v-model="description" class="_formBlock">
+ <template #label>{{ i18n.ts.instanceDescription }}</template>
+ </FormTextarea>
- <FormInput v-model="tosUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.tosUrl }}</template>
- </FormInput>
+ <FormInput v-model="tosUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-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>
+ <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="fas fa-envelope"></i></template>
- <template #label>{{ i18n.ts.maintainerEmail }}</template>
- </FormInput>
- </FormSplit>
+ <FormInput v-model="maintainerEmail" type="email" class="_formBlock">
+ <template #prefix><i class="fas fa-envelope"></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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <FormSection>
+ <template #label>{{ i18n.ts.theme }}</template>
- <FormInput v-model="iconUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.iconUrl }}</template>
- </FormInput>
+ <FormInput v-model="iconUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>{{ i18n.ts.iconUrl }}</template>
+ </FormInput>
- <FormInput v-model="bannerUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.bannerUrl }}</template>
- </FormInput>
+ <FormInput v-model="bannerUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>{{ i18n.ts.bannerUrl }}</template>
+ </FormInput>
- <FormInput v-model="backgroundImageUrl" class="_formBlock">
- <template #prefix><i class="fas fa-link"></i></template>
- <template #label>{{ i18n.ts.backgroundImageUrl }}</template>
- </FormInput>
+ <FormInput v-model="backgroundImageUrl" class="_formBlock">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <template #label>{{ i18n.ts.backgroundImageUrl }}</template>
+ </FormInput>
- <FormInput v-model="themeColor" class="_formBlock">
- <template #prefix><i class="fas fa-palette"></i></template>
- <template #label>{{ i18n.ts.themeColor }}</template>
- <template #caption>#RRGGBB</template>
- </FormInput>
+ <FormInput v-model="themeColor" class="_formBlock">
+ <template #prefix><i class="fas fa-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="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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <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>
+ <FormSection>
+ <template #label>ServiceWorker</template>
- <FormSwitch v-model="enableServiceWorker" class="_formBlock">
- <template #label>{{ i18n.ts.enableServiceworker }}</template>
- <template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
- </FormSwitch>
+ <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="fas fa-key"></i></template>
- <template #label>Public key</template>
- </FormInput>
+ <template v-if="enableServiceWorker">
+ <FormInput v-model="swPublicKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Public key</template>
+ </FormInput>
- <FormInput v-model="swPrivateKey" class="_formBlock">
- <template #prefix><i class="fas fa-key"></i></template>
- <template #label>Private key</template>
- </FormInput>
- </template>
- </FormSection>
+ <FormInput v-model="swPrivateKey" class="_formBlock">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <template #label>Private key</template>
+ </FormInput>
+ </template>
+ </FormSection>
- <FormSection>
- <template #label>DeepL Translation</template>
+ <FormSection>
+ <template #label>DeepL Translation</template>
- <FormInput v-model="deeplAuthKey" class="_formBlock">
- <template #prefix><i class="fas fa-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>
+ <FormInput v-model="deeplAuthKey" class="_formBlock">
+ <template #prefix><i class="fas fa-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';
@@ -152,9 +158,9 @@ 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 * as symbols from '@/symbols';
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);
@@ -240,17 +246,17 @@ function save() {
});
}
-defineExpose({
- [symbols.PAGE_INFO]: {
- title: i18n.ts.general,
- icon: 'fas fa-cog',
- bg: 'var(--bg)',
- actions: [{
- asFullButton: true,
- icon: 'fas fa-check',
- text: i18n.ts.save,
- handler: save,
- }],
- }
+const headerActions = $computed(() => [{
+ asFullButton: true,
+ icon: 'fas fa-check',
+ text: i18n.ts.save,
+ handler: save,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.general,
+ icon: 'fas fa-cog',
});
</script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
index f05aa5ff45..c6755672f7 100644
--- a/packages/client/src/pages/admin/users.vue
+++ b/packages/client/src/pages/admin/users.vue
@@ -1,76 +1,68 @@
<template>
-<div class="lknzcolw">
- <div class="users">
- <div class="inputs">
- <MkSelect v-model="sort" style="flex: 1;">
- <template #label>{{ $ts.sort }}</template>
- <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
- <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
- <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
- <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
- </MkSelect>
- <MkSelect v-model="state" style="flex: 1;">
- <template #label>{{ $ts.state }}</template>
- <option value="all">{{ $ts.all }}</option>
- <option value="available">{{ $ts.normal }}</option>
- <option value="admin">{{ $ts.administrator }}</option>
- <option value="moderator">{{ $ts.moderator }}</option>
- <option value="silenced">{{ $ts.silence }}</option>
- <option value="suspended">{{ $ts.suspend }}</option>
- </MkSelect>
- <MkSelect v-model="origin" style="flex: 1;">
- <template #label>{{ $ts.instance }}</template>
- <option value="combined">{{ $ts.all }}</option>
- <option value="local">{{ $ts.local }}</option>
- <option value="remote">{{ $ts.remote }}</option>
- </MkSelect>
- </div>
- <div class="inputs">
- <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
- <template #prefix>@</template>
- <template #label>{{ $ts.username }}</template>
- </MkInput>
- <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
- <template #prefix>@</template>
- <template #label>{{ $ts.host }}</template>
- </MkInput>
- </div>
-
- <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
- <button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
- <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
- <div class="body">
- <header>
- <MkUserName class="name" :user="user"/>
- <span class="acct">@{{ acct(user) }}</span>
- <span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span>
- <span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span>
- <span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
- <span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
- </header>
- <div>
- <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
+<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>{{ $ts.sort }}</template>
+ <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+ <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+ </MkSelect>
+ <MkSelect v-model="state" style="flex: 1;">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="available">{{ $ts.normal }}</option>
+ <option value="admin">{{ $ts.administrator }}</option>
+ <option value="moderator">{{ $ts.moderator }}</option>
+ <option value="silenced">{{ $ts.silence }}</option>
+ <option value="suspended">{{ $ts.suspend }}</option>
+ </MkSelect>
+ <MkSelect v-model="origin" style="flex: 1;">
+ <template #label>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
</div>
- <div>
- <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+ <div class="inputs">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.username }}</template>
+ </MkInput>
+ <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ $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>
- </button>
- </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/ui/pagination.vue';
-import { acct } from '@/filters/user';
import * as os from '@/os';
-import * as symbols from '@/symbols';
import { lookupUser } from '@/scripts/lookup-user';
import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkUserCardMini from '@/components/user-card-mini.vue';
let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
@@ -89,7 +81,7 @@ const pagination = {
username: searchUsername,
hostname: searchHost,
})),
- offsetMode: true
+ offsetMode: true,
};
function searchUser() {
@@ -106,7 +98,7 @@ async function addUser() {
const { canceled: canceled2, result: password } = await os.inputText({
title: i18n.ts.password,
- type: 'password'
+ type: 'password',
});
if (canceled2) return;
@@ -122,34 +114,33 @@ function show(user) {
os.pageWindow(`/user-info/${user.id}`);
}
-defineExpose({
- [symbols.PAGE_INFO]: computed(() => ({
- title: i18n.ts.users,
- icon: 'fas fa-users',
- bg: 'var(--bg)',
- actions: [{
- icon: 'fas fa-search',
- text: i18n.ts.search,
- handler: searchUser
- }, {
- asFullButton: true,
- icon: 'fas fa-plus',
- text: i18n.ts.addUser,
- handler: addUser
- }, {
- asFullButton: true,
- icon: 'fas fa-search',
- text: i18n.ts.lookup,
- handler: lookupUser
- }],
- })),
-});
+const headerActions = $computed(() => [{
+ icon: 'fas fa-search',
+ text: i18n.ts.search,
+ handler: searchUser,
+}, {
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: i18n.ts.addUser,
+ handler: addUser,
+}, {
+ asFullButton: true,
+ icon: 'fas fa-search',
+ text: i18n.ts.lookup,
+ handler: lookupUser,
+}]);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata(computed(() => ({
+ title: i18n.ts.users,
+ icon: 'fas fa-users',
+})));
</script>
<style lang="scss" scoped>
.lknzcolw {
> .users {
- margin: var(--margin);
> .inputs {
display: flex;
@@ -166,54 +157,12 @@ defineExpose({
> .users {
margin-top: var(--margin);
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ grid-gap: 12px;
- > .user {
- display: flex;
- width: 100%;
- box-sizing: border-box;
- text-align: left;
- align-items: center;
- padding: 16px;
-
- &:hover {
- color: var(--accent);
- }
-
- > .avatar {
- width: 60px;
- height: 60px;
- }
-
- > .body {
- margin-left: 0.3em;
- padding: 0 8px;
- flex: 1;
-
- @media (max-width: 500px) {
- font-size: 14px;
- }
-
- > header {
- > .name {
- font-weight: bold;
- }
-
- > .acct {
- margin-left: 8px;
- opacity: 0.7;
- }
-
- > .staff {
- margin-left: 0.5em;
- color: var(--badge);
- }
-
- > .punished {
- margin-left: 0.5em;
- color: #4dabf7;
- }
- }
- }
+ > .user:hover {
+ text-decoration: none;
}
}
}