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