diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-10-23 01:08:45 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-10-23 01:08:45 +0900 |
| commit | d0d5068f728e13f3ebe1dc227ddaacf380817ec4 (patch) | |
| tree | 7bb95207e01bff1bee9877829c0556d3ecf62176 /src/client | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.93.0 (diff) | |
| download | misskey-d0d5068f728e13f3ebe1dc227ddaacf380817ec4.tar.gz misskey-d0d5068f728e13f3ebe1dc227ddaacf380817ec4.tar.bz2 misskey-d0d5068f728e13f3ebe1dc227ddaacf380817ec4.zip | |
Merge branch 'develop'
Diffstat (limited to 'src/client')
75 files changed, 1879 insertions, 1582 deletions
diff --git a/src/client/components/chart.vue b/src/client/components/chart.vue new file mode 100644 index 0000000000..8eb9f93f33 --- /dev/null +++ b/src/client/components/chart.vue @@ -0,0 +1,628 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref, watch, PropType } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import { enUS } from 'date-fns/locale'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import * as os from '@client/os'; +import { defaultStore } from '@client/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + zoomPlugin, +); + +const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = arr => arr.map(x => -x); +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 colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560']; +const getColor = (i) => { + return colors[i % colors.length]; +}; + +export default defineComponent({ + props: { + src: { + type: String, + required: true, + }, + args: { + type: Object, + required: false, + }, + limit: { + type: Number, + required: false, + default: 90 + }, + span: { + type: String as PropType<'hour' | 'day'>, + required: true, + }, + detailed: { + type: Boolean, + required: false, + default: false + }, + }, + + setup(props) { + const now = new Date(); + let chartInstance: Chart = null; + let data: { + series: { + name: string; + type: 'line' | 'area'; + color?: string; + borderDash?: number[]; + hidden?: boolean; + data: { + x: number; + y: number; + }[]; + }[]; + } = null; + + const chartEl = ref<HTMLCanvasElement>(null); + const fetching = ref(true); + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v + })); + }; + + const render = () => { + if (chartInstance) { + chartInstance.destroy(); + } + + 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'); + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), + datasets: data.series.map((x, i) => ({ + parsing: false, + label: x.name, + data: x.data.slice().reverse(), + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: x.color ? x.color : getColor(i), + borderDash: x.borderDash || [], + borderJoinStyle: 'round', + backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1), + fill: x.type === 'area', + hidden: !!x.hidden, + })), + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 8, + }, + }, + scales: { + x: { + type: 'time', + time: { + stepSize: 1, + unit: props.span === 'day' ? 'month' : 'day', + }, + grid: { + display: props.detailed, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(props.limit).getTime(), + }, + y: { + position: 'left', + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + tooltip: { + mode: 'index', + animation: { + duration: 0, + }, + }, + zoom: { + pan: { + enabled: true, + }, + zoom: { + wheel: { + enabled: true, + }, + pinch: { + enabled: true, + }, + drag: { + enabled: false, + }, + mode: 'x', + }, + limits: { + x: { + min: 'original', + max: 'original', + }, + y: { + min: 'original', + max: 'original', + }, + } + }, + }, + }, + }); + }; + + const exportData = () => { + // TODO + }; + + const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Instances', + type: 'area', + data: format(total + ? raw.instance.total + : sum(raw.instance.inc, negate(raw.instance.dec)) + ), + }], + }; + }; + + const fetchNotesChart = async (type: string): Promise<typeof data> => { + const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format(type == 'combined' + ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + : sum(raw[type].inc, negate(raw[type].dec)) + ), + }, { + name: 'Renotes', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) + : raw[type].diffs.renote + ), + }, { + name: 'Replies', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) + : raw[type].diffs.reply + ), + }, { + name: 'Normal', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) + : raw[type].diffs.normal + ), + }], + }; + }; + + const fetchNotesTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.total, raw.remote.total)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.total), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.total), + }], + }; + }; + + const fetchUsersChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(total + ? sum(raw.local.total, raw.remote.total) + : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + ), + }, { + name: 'Local', + type: 'area', + data: format(total + ? raw.local.total + : sum(raw.local.inc, negate(raw.local.dec)) + ), + }, { + name: 'Remote', + type: 'area', + data: format(total + ? raw.remote.total + : sum(raw.remote.inc, negate(raw.remote.dec)) + ), + }], + }; + }; + + const fetchActiveUsersChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.users, raw.remote.users)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.users), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.users), + }], + }; + }; + + const fetchDriveChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format( + sum( + raw.local.incSize, + negate(raw.local.decSize), + raw.remote.incSize, + negate(raw.remote.decSize) + ) + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incSize), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decSize)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incSize), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decSize)), + }], + }; + }; + + const fetchDriveTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.totalSize, raw.remote.totalSize)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.totalSize), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.totalSize), + }], + }; + }; + + const fetchDriveFilesChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format( + sum( + raw.local.incCount, + negate(raw.local.decCount), + raw.remote.incCount, + negate(raw.remote.decCount) + ) + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incCount), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decCount)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incCount), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decCount)), + }], + }; + }; + + const fetchDriveFilesTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.totalCount, raw.remote.totalCount)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.totalCount), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.totalCount), + }], + }; + }; + + const fetchInstanceRequestsChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'In', + type: 'area', + color: '#008FFB', + data: format(raw.requests.received) + }, { + name: 'Out (succ)', + type: 'area', + color: '#00E396', + data: format(raw.requests.succeeded) + }, { + name: 'Out (fail)', + type: 'area', + color: '#FEB019', + data: format(raw.requests.failed) + }] + }; + }; + + const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Users', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.users.total + : sum(raw.users.inc, negate(raw.users.dec)) + ) + }] + }; + }; + + const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Notes', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.notes.total + : sum(raw.notes.inc, negate(raw.notes.dec)) + ) + }] + }; + }; + + const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Following', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.following.total + : sum(raw.following.inc, negate(raw.following.dec)) + ) + }, { + name: 'Followers', + type: 'area', + color: '#00E396', + data: format(total + ? raw.followers.total + : sum(raw.followers.inc, negate(raw.followers.dec)) + ) + }] + }; + }; + + const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'Drive usage', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalUsage + : sum(raw.drive.incUsage, negate(raw.drive.decUsage)) + ) + }] + }; + }; + + const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Drive files', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalFiles + : sum(raw.drive.incFiles, negate(raw.drive.decFiles)) + ) + }] + }; + }; + + const fetchAndRender = async () => { + const fetchData = () => { + switch (props.src) { + case 'federation-instances': return fetchFederationInstancesChart(false); + case 'federation-instances-total': return fetchFederationInstancesChart(true); + case 'users': return fetchUsersChart(false); + case 'users-total': return fetchUsersChart(true); + case 'active-users': return fetchActiveUsersChart(); + case 'notes': return fetchNotesChart('combined'); + case 'local-notes': return fetchNotesChart('local'); + case 'remote-notes': return fetchNotesChart('remote'); + case 'notes-total': return fetchNotesTotalChart(); + case 'drive': return fetchDriveChart(); + case 'drive-total': return fetchDriveTotalChart(); + case 'drive-files': return fetchDriveFilesChart(); + case 'drive-files-total': return fetchDriveFilesTotalChart(); + + case 'instance-requests': return fetchInstanceRequestsChart(); + case 'instance-users': return fetchInstanceUsersChart(false); + case 'instance-users-total': return fetchInstanceUsersChart(true); + case 'instance-notes': return fetchInstanceNotesChart(false); + case 'instance-notes-total': return fetchInstanceNotesChart(true); + case 'instance-ff': return fetchInstanceFfChart(false); + case 'instance-ff-total': return fetchInstanceFfChart(true); + case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false); + case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true); + case 'instance-drive-files': return fetchInstanceDriveFilesChart(false); + case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true); + } + }; + fetching.value = true; + data = await fetchData(); + fetching.value = false; + render(); + }; + + watch(() => [props.src, props.span], fetchAndRender); + + onMounted(() => { + fetchAndRender(); + }); + + return { + chartEl, + }; + }, +}); +</script> diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue index d7b6f77519..591eda9ed5 100644 --- a/src/client/components/form/input.vue +++ b/src/client/components/form/input.vue @@ -33,7 +33,7 @@ <script lang="ts"> import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '../ui/button.vue'; +import MkButton from '@client/components/ui/button.vue'; import { debounce } from 'throttle-debounce'; export default defineComponent({ diff --git a/src/client/components/form/radios.vue b/src/client/components/form/radios.vue index 1d3d80172a..998a738202 100644 --- a/src/client/components/form/radios.vue +++ b/src/client/components/form/radios.vue @@ -22,7 +22,6 @@ export default defineComponent({ } }, render() { - const label = this.$slots.desc(); let options = this.$slots.default(); // なぜかFragmentになることがあるため @@ -31,7 +30,6 @@ export default defineComponent({ return h('div', { class: 'novjtcto' }, [ - h('div', { class: 'label' }, label), ...options.map(option => h(MkRadio, { key: option.key, value: option.props.value, @@ -45,16 +43,6 @@ export default defineComponent({ <style lang="scss"> .novjtcto { - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; - - &:empty { - display: none; - } - } - &:first-child { margin-top: 0; } diff --git a/src/client/components/form/select.vue b/src/client/components/form/select.vue index 257e2cc990..30ccfd312b 100644 --- a/src/client/components/form/select.vue +++ b/src/client/components/form/select.vue @@ -1,9 +1,9 @@ <template> <div class="vblkjoeq"> <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ inline, disabled, focused }"> + <div class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick" ref="container"> <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <select ref="inputEl" + <select class="select" ref="inputEl" v-model="v" :disabled="disabled" :required="required" @@ -25,7 +25,8 @@ <script lang="ts"> import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '../ui/button.vue'; +import MkButton from '@client/components/ui/button.vue'; +import * as os from '@client/os'; export default defineComponent({ components: { @@ -81,6 +82,7 @@ export default defineComponent({ const inputEl = ref(null); const prefixEl = ref(null); const suffixEl = ref(null); + const container = ref(null); const focus = () => inputEl.value.focus(); const onInput = (ev) => { @@ -132,6 +134,47 @@ export default defineComponent({ }); }); + const onClick = (ev: MouseEvent) => { + focused.value = true; + + const menu = []; + let options = context.slots.default(); + + for (const optionOrOptgroup of options) { + if (optionOrOptgroup.type === 'optgroup') { + const optgroup = optionOrOptgroup; + menu.push({ + type: 'label', + text: optgroup.props.label, + }); + for (const option of optgroup.children) { + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + } + } else { + const option = optionOrOptgroup; + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + } + } + + os.popupMenu(menu, container.value, { + width: container.value.offsetWidth, + }).then(() => { + focused.value = false; + }); + }; + return { v, focused, @@ -141,8 +184,10 @@ export default defineComponent({ inputEl, prefixEl, suffixEl, + container, focus, onInput, + onClick, updated, }; }, @@ -174,8 +219,15 @@ export default defineComponent({ > .input { $height: 42px; position: relative; + cursor: pointer; - > select { + &:hover { + > .select { + border-color: var(--inputBorderHover); + } + } + + > .select { appearance: none; -webkit-appearance: none; display: block; @@ -195,10 +247,7 @@ export default defineComponent({ box-sizing: border-box; cursor: pointer; transition: border-color 0.1s ease-out; - - &:hover { - border-color: var(--inputBorderHover); - } + pointer-events: none; } > .prefix, diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue index 50be69f930..048e9032df 100644 --- a/src/client/components/form/textarea.vue +++ b/src/client/components/form/textarea.vue @@ -26,7 +26,7 @@ <script lang="ts"> import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '../ui/button.vue'; +import MkButton from '@client/components/ui/button.vue'; import { debounce } from 'throttle-debounce'; export default defineComponent({ diff --git a/src/client/components/global/emoji.vue b/src/client/components/global/emoji.vue index f4ebd5f3b3..f92e35c38f 100644 --- a/src/client/components/global/emoji.vue +++ b/src/client/components/global/emoji.vue @@ -27,8 +27,7 @@ export default defineComponent({ default: false }, customEmojis: { - required: false, - default: () => [] + required: false }, isReaction: { type: Boolean, @@ -58,10 +57,7 @@ export default defineComponent({ }, ce() { - let ce = []; - if (this.customEmojis) ce = ce.concat(this.customEmojis); - if (this.$instance && this.$instance.emojis) ce = ce.concat(this.$instance.emojis); - return ce; + return this.customEmojis || this.$instance?.emojis || []; } }, diff --git a/src/client/components/global/header.vue b/src/client/components/global/header.vue index a4466da498..2bf490c98a 100644 --- a/src/client/components/global/header.vue +++ b/src/client/components/global/header.vue @@ -203,6 +203,12 @@ export default defineComponent({ &.thin { --height: 50px; + + > .buttons { + > .button { + font-size: 0.9em; + } + } } &.slim { diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue index 5e7c71ea65..fd0b75609f 100644 --- a/src/client/components/instance-stats.vue +++ b/src/client/components/instance-stats.vue @@ -24,35 +24,26 @@ <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option> </optgroup> </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0;"> + <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> <option value="hour">{{ $ts.perHour }}</option> <option value="day">{{ $ts.perDay }}</option> </MkSelect> </div> - <canvas ref="chart"></canvas> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> </div> </template> <script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import Chart from 'chart.js'; -import MkSelect from './form/select.vue'; -import number from '@client/filters/number'; - -const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); -const negate = arr => arr.map(x => -x); -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 { defineComponent, onMounted, ref, watch } from 'vue'; +import MkSelect from '@client/components/form/select.vue'; +import MkChart from '@client/components/chart.vue'; import * as os from '@client/os'; +import { defaultStore } from '@client/store'; export default defineComponent({ components: { - MkSelect + MkSelect, + MkChart, }, props: { @@ -68,463 +59,15 @@ export default defineComponent({ }, }, - data() { - return { - notesLocalWoW: 0, - notesLocalDoD: 0, - notesRemoteWoW: 0, - notesRemoteDoD: 0, - usersLocalWoW: 0, - usersLocalDoD: 0, - usersRemoteWoW: 0, - usersRemoteDoD: 0, - now: null, - chart: null, - chartInstance: null, - chartSrc: 'notes', - chartSpan: 'hour', - } - }, - - computed: { - data(): any { - if (this.chart == null) return null; - switch (this.chartSrc) { - case 'federation-instances': return this.federationInstancesChart(false); - case 'federation-instances-total': return this.federationInstancesChart(true); - case 'users': return this.usersChart(false); - case 'users-total': return this.usersChart(true); - case 'active-users': return this.activeUsersChart(); - case 'notes': return this.notesChart('combined'); - case 'local-notes': return this.notesChart('local'); - case 'remote-notes': return this.notesChart('remote'); - case 'notes-total': return this.notesTotalChart(); - case 'drive': return this.driveChart(); - case 'drive-total': return this.driveTotalChart(); - case 'drive-files': return this.driveFilesChart(); - case 'drive-files-total': return this.driveFilesTotalChart(); - } - }, - - stats(): any[] { - const stats = - this.chartSpan == 'day' ? this.chart.perDay : - this.chartSpan == 'hour' ? this.chart.perHour : - null; - - return stats; - } - }, - - watch: { - chartSrc() { - this.renderChart(); - }, - - chartSpan() { - this.renderChart(); - } - }, - - async created() { - this.now = new Date(); + setup() { + const chartSpan = ref<'hour' | 'day'>('hour'); + const chartSrc = ref('notes'); - this.fetchChart(); + return { + chartSrc, + chartSpan, + }; }, - - methods: { - async fetchChart() { - const [perHour, perDay] = await Promise.all([Promise.all([ - os.api('charts/federation', { limit: this.chartLimit, span: 'hour' }), - os.api('charts/users', { limit: this.chartLimit, span: 'hour' }), - os.api('charts/active-users', { limit: this.chartLimit, span: 'hour' }), - os.api('charts/notes', { limit: this.chartLimit, span: 'hour' }), - os.api('charts/drive', { limit: this.chartLimit, span: 'hour' }), - ]), Promise.all([ - os.api('charts/federation', { limit: this.chartLimit, span: 'day' }), - os.api('charts/users', { limit: this.chartLimit, span: 'day' }), - os.api('charts/active-users', { limit: this.chartLimit, span: 'day' }), - os.api('charts/notes', { limit: this.chartLimit, span: 'day' }), - os.api('charts/drive', { limit: this.chartLimit, span: 'day' }), - ])]); - - const chart = { - perHour: { - federation: perHour[0], - users: perHour[1], - activeUsers: perHour[2], - notes: perHour[3], - drive: perHour[4], - }, - perDay: { - federation: perDay[0], - users: perDay[1], - activeUsers: perDay[2], - notes: perDay[3], - drive: perDay[4], - } - }; - - this.chart = chart; - - this.renderChart(); - }, - - renderChart() { - if (this.chartInstance) { - this.chartInstance.destroy(); - } - - // TODO: var(--panel)の色が暗いか明るいかで判定する - const gridColor = this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - this.chartInstance = markRaw(new Chart(this.$refs.chart, { - type: 'line', - data: { - labels: new Array(this.chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), - datasets: this.data.series.map(x => ({ - label: x.name, - data: x.data.slice().reverse(), - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: x.color, - borderDash: x.borderDash || [], - backgroundColor: alpha(x.color, 0.1), - fill: x.fill == null ? true : x.fill, - hidden: !!x.hidden - })) - }, - options: { - aspectRatio: 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 8 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - type: 'time', - time: { - stepSize: 1, - unit: this.chartSpan == 'day' ? 'month' : 'day', - }, - gridLines: { - display: this.detailed, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: this.detailed - } - }], - yAxes: [{ - position: 'left', - gridLines: { - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: this.detailed - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - })); - }, - - getDate(ago: number) { - const y = this.now.getFullYear(); - const m = this.now.getMonth(); - const d = this.now.getDate(); - const h = this.now.getHours(); - - return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); - }, - - format(arr) { - const now = Date.now(); - return arr.map((v, i) => ({ - x: new Date(now - ((this.chartSpan == 'day' ? 86400000 :3600000 ) * i)), - y: v - })); - }, - - federationInstancesChart(total: boolean): any { - return { - series: [{ - name: 'Instances', - color: '#008FFB', - data: this.format(total - ? this.stats.federation.instance.total - : sum(this.stats.federation.instance.inc, negate(this.stats.federation.instance.dec)) - ) - }] - }; - }, - - notesChart(type: string): any { - return { - series: [{ - name: 'All', - type: 'line', - color: '#008FFB', - borderDash: [5, 5], - fill: false, - data: this.format(type == 'combined' - ? sum(this.stats.notes.local.inc, negate(this.stats.notes.local.dec), this.stats.notes.remote.inc, negate(this.stats.notes.remote.dec)) - : sum(this.stats.notes[type].inc, negate(this.stats.notes[type].dec)) - ) - }, { - name: 'Renotes', - type: 'area', - color: '#00E396', - data: this.format(type == 'combined' - ? sum(this.stats.notes.local.diffs.renote, this.stats.notes.remote.diffs.renote) - : this.stats.notes[type].diffs.renote - ) - }, { - name: 'Replies', - type: 'area', - color: '#FEB019', - data: this.format(type == 'combined' - ? sum(this.stats.notes.local.diffs.reply, this.stats.notes.remote.diffs.reply) - : this.stats.notes[type].diffs.reply - ) - }, { - name: 'Normal', - type: 'area', - color: '#FF4560', - data: this.format(type == 'combined' - ? sum(this.stats.notes.local.diffs.normal, this.stats.notes.remote.diffs.normal) - : this.stats.notes[type].diffs.normal - ) - }] - }; - }, - - notesTotalChart(): any { - return { - series: [{ - name: 'Combined', - type: 'line', - color: '#008FFB', - data: this.format(sum(this.stats.notes.local.total, this.stats.notes.remote.total)) - }, { - name: 'Local', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.notes.local.total) - }, { - name: 'Remote', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.notes.remote.total) - }] - }; - }, - - usersChart(total: boolean): any { - return { - series: [{ - name: 'Combined', - type: 'line', - color: '#008FFB', - data: this.format(total - ? sum(this.stats.users.local.total, this.stats.users.remote.total) - : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec), this.stats.users.remote.inc, negate(this.stats.users.remote.dec)) - ) - }, { - name: 'Local', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(total - ? this.stats.users.local.total - : sum(this.stats.users.local.inc, negate(this.stats.users.local.dec)) - ) - }, { - name: 'Remote', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(total - ? this.stats.users.remote.total - : sum(this.stats.users.remote.inc, negate(this.stats.users.remote.dec)) - ) - }] - }; - }, - - activeUsersChart(): any { - return { - series: [{ - name: 'Combined', - type: 'line', - color: '#008FFB', - data: this.format(sum(this.stats.activeUsers.local.count, this.stats.activeUsers.remote.count)) - }, { - name: 'Local', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.activeUsers.local.count) - }, { - name: 'Remote', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.activeUsers.remote.count) - }] - }; - }, - - driveChart(): any { - return { - bytes: true, - series: [{ - name: 'All', - type: 'line', - color: '#09d8e2', - borderDash: [5, 5], - fill: false, - data: this.format( - sum( - this.stats.drive.local.incSize, - negate(this.stats.drive.local.decSize), - this.stats.drive.remote.incSize, - negate(this.stats.drive.remote.decSize) - ) - ) - }, { - name: 'Local +', - type: 'area', - color: '#008FFB', - data: this.format(this.stats.drive.local.incSize) - }, { - name: 'Local -', - type: 'area', - color: '#FF4560', - data: this.format(negate(this.stats.drive.local.decSize)) - }, { - name: 'Remote +', - type: 'area', - color: '#00E396', - data: this.format(this.stats.drive.remote.incSize) - }, { - name: 'Remote -', - type: 'area', - color: '#FEB019', - data: this.format(negate(this.stats.drive.remote.decSize)) - }] - }; - }, - - driveTotalChart(): any { - return { - bytes: true, - series: [{ - name: 'Combined', - type: 'line', - color: '#008FFB', - data: this.format(sum(this.stats.drive.local.totalSize, this.stats.drive.remote.totalSize)) - }, { - name: 'Local', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.drive.local.totalSize) - }, { - name: 'Remote', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.drive.remote.totalSize) - }] - }; - }, - - driveFilesChart(): any { - return { - series: [{ - name: 'All', - type: 'line', - color: '#09d8e2', - borderDash: [5, 5], - fill: false, - data: this.format( - sum( - this.stats.drive.local.incCount, - negate(this.stats.drive.local.decCount), - this.stats.drive.remote.incCount, - negate(this.stats.drive.remote.decCount) - ) - ) - }, { - name: 'Local +', - type: 'area', - color: '#008FFB', - data: this.format(this.stats.drive.local.incCount) - }, { - name: 'Local -', - type: 'area', - color: '#FF4560', - data: this.format(negate(this.stats.drive.local.decCount)) - }, { - name: 'Remote +', - type: 'area', - color: '#00E396', - data: this.format(this.stats.drive.remote.incCount) - }, { - name: 'Remote -', - type: 'area', - color: '#FEB019', - data: this.format(negate(this.stats.drive.remote.decCount)) - }] - }; - }, - - driveFilesTotalChart(): any { - return { - series: [{ - name: 'Combined', - type: 'line', - color: '#008FFB', - data: this.format(sum(this.stats.drive.local.totalCount, this.stats.drive.remote.totalCount)) - }, { - name: 'Local', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.drive.local.totalCount) - }, { - name: 'Remote', - type: 'area', - color: '#008FFB', - hidden: true, - data: this.format(this.stats.drive.remote.totalCount) - }] - }; - }, - - number - } }); </script> diff --git a/src/client/components/mfm.ts b/src/client/components/mfm.ts index 2bdd7d46ee..f3411cadc3 100644 --- a/src/client/components/mfm.ts +++ b/src/client/components/mfm.ts @@ -185,7 +185,7 @@ export default defineComponent({ } } if (style == null) { - return h('span', {}, ['[', token.props.name, ' ', ...genEl(token.children), ']']); + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); } else { return h('span', { style: 'display: inline-block;' + style, diff --git a/src/client/components/number-diff.vue b/src/client/components/number-diff.vue new file mode 100644 index 0000000000..690f89dd59 --- /dev/null +++ b/src/client/components/number-diff.vue @@ -0,0 +1,47 @@ +<template> +<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }"> + <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot> +</span> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import number from '@client/filters/number'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: true + }, + }, + + setup(props) { + const isPlus = computed(() => props.value > 0); + const isMinus = computed(() => props.value < 0); + const isZero = computed(() => props.value === 0); + return { + isPlus, + isMinus, + isZero, + number, + }; + } +}); +</script> + +<style lang="scss" scoped> +.ceaaebcd { + &.isPlus { + color: var(--success); + } + + &.isMinus { + color: var(--error); + } + + &.isZero { + opacity: 0.5; + } +} +</style> diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue index a1d89d2a2e..816a69e731 100644 --- a/src/client/components/post-form.vue +++ b/src/client/components/post-form.vue @@ -117,11 +117,28 @@ export default defineComponent({ type: String, required: false }, + initialVisibility: { + type: String, + required: false + }, + initialFiles: { + type: Array, + required: false + }, + initialLocalOnly: { + type: Boolean, + required: false + }, + visibleUsers: { + type: Array, + required: false, + default: () => [] + }, initialNote: { type: Object, required: false }, - instant: { + share: { type: Boolean, required: false, default: false @@ -150,8 +167,7 @@ export default defineComponent({ showPreview: false, cw: null, localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, - visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility, - visibleUsers: [], + visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number], autocomplete: null, draghover: false, quoteId: null, @@ -246,6 +262,18 @@ export default defineComponent({ this.text = this.initialText; } + if (this.initialVisibility) { + this.visibility = this.initialVisibility; + } + + if (this.initialFiles) { + this.files = this.initialFiles; + } + + if (typeof this.initialLocalOnly === 'boolean') { + this.localOnly = this.initialLocalOnly; + } + if (this.mention) { this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; this.text += ' '; @@ -321,7 +349,7 @@ export default defineComponent({ this.$nextTick(() => { // 書きかけの投稿を復元 - if (!this.instant && !this.mention && !this.specified) { + if (!this.share && !this.mention && !this.specified) { const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; if (draft) { this.text = draft.data.text; @@ -582,8 +610,6 @@ export default defineComponent({ }, saveDraft() { - if (this.instant) return; - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); data[this.draftKey] = { diff --git a/src/client/components/queue-chart.vue b/src/client/components/queue-chart.vue new file mode 100644 index 0000000000..59c9723f89 --- /dev/null +++ b/src/client/components/queue-chart.vue @@ -0,0 +1,212 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@client/filters/number'; +import * as os from '@client/os'; +import { defaultStore } from '@client/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +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})`; +}; + +export default defineComponent({ + props: { + domain: { + type: String, + required: true, + }, + connection: { + required: true, + }, + }, + + setup(props) { + 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'); + + onMounted(() => { + const chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Process', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#00E396', + backgroundColor: alpha('#00E396', 0.1), + data: [] + }, { + label: 'Active', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#00BCD4', + backgroundColor: alpha('#00BCD4', 0.1), + data: [] + }, { + label: 'Waiting', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#FFB300', + backgroundColor: alpha('#FFB300', 0.1), + data: [] + }, { + label: 'Delayed', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#E53935', + borderDash: [5, 5], + fill: false, + data: [] + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 8, + }, + }, + scales: { + x: { + grid: { + display: false, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + }, + }, + y: { + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + tooltip: { + mode: 'index', + animation: { + duration: 0, + }, + }, + }, + }, + }); + + const onStats = (stats) => { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + chartInstance.update(); + }; + + const onStatsLog = (statsLog) => { + for (const stats of [...statsLog].reverse()) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + } + chartInstance.update(); + }; + + props.connection.on('stats', onStats); + props.connection.on('statsLog', onStatsLog); + + onUnmounted(() => { + props.connection.off('stats', onStats); + props.connection.off('statsLog', onStatsLog); + }); + }); + + return { + chartEl, + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue index ce86af8f95..c629727358 100644 --- a/src/client/components/tab.vue +++ b/src/client/components/tab.vue @@ -36,7 +36,7 @@ export default defineComponent({ > button { flex: 1; padding: 10px 8px; - border-radius: 6px; + border-radius: var(--radius); &:disabled { opacity: 1 !important; diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue index 1940099096..14673dfcd7 100644 --- a/src/client/components/ui/container.vue +++ b/src/client/components/ui/container.vue @@ -1,5 +1,5 @@ <template> -<div class="ukygtjoj _block" :class="{ naked, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }"> +<div class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }"> <header v-if="showHeader" ref="header"> <div class="title"><slot name="header"></slot></div> <div class="sub"> @@ -36,6 +36,11 @@ export default defineComponent({ required: false, default: true }, + thin: { + type: Boolean, + required: false, + default: false + }, naked: { type: Boolean, required: false, @@ -226,7 +231,7 @@ export default defineComponent({ } } - &.max-width_380px { + &.max-width_380px, &.thin { > header { > .title { padding: 8px 10px; diff --git a/src/client/components/ui/menu.vue b/src/client/components/ui/menu.vue index da24d90170..aaef527f1a 100644 --- a/src/client/components/ui/menu.vue +++ b/src/client/components/ui/menu.vue @@ -1,5 +1,6 @@ <template> <div class="rrevdjwt" :class="{ center: align === 'center' }" + :style="{ width: width ? width + 'px' : null }" ref="items" @contextmenu.self="e => e.preventDefault()" v-hotkey="keymap" @@ -59,6 +60,10 @@ export default defineComponent({ type: String, requried: false }, + width: { + type: Number, + required: false + }, }, emits: ['close'], data() { diff --git a/src/client/components/ui/popup-menu.vue b/src/client/components/ui/popup-menu.vue index 23f7c89f3b..3ff4c658b1 100644 --- a/src/client/components/ui/popup-menu.vue +++ b/src/client/components/ui/popup-menu.vue @@ -1,6 +1,6 @@ <template> <MkPopup ref="popup" :src="src" @closed="$emit('closed')"> - <MkMenu :items="items" :align="align" @close="$refs.popup.close()" class="_popup _shadow"/> + <MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/> </MkPopup> </template> @@ -24,6 +24,10 @@ export default defineComponent({ type: String, required: false }, + width: { + type: Number, + required: false + }, viaKeyboard: { type: Boolean, required: false diff --git a/src/client/components/ui/super-menu.vue b/src/client/components/ui/super-menu.vue index 35fc81550d..6ab94d744d 100644 --- a/src/client/components/ui/super-menu.vue +++ b/src/client/components/ui/super-menu.vue @@ -120,7 +120,7 @@ export default defineComponent({ > .items { display: grid; - grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); grid-gap: 8px; padding: 0 16px; diff --git a/src/client/os.ts b/src/client/os.ts index 7ae774dd92..743d2d131f 100644 --- a/src/client/os.ts +++ b/src/client/os.ts @@ -372,12 +372,17 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: }); } -export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: { align?: string; viaKeyboard?: boolean }) { +export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: { + align?: string; + width?: number; + viaKeyboard?: boolean; +}) { return new Promise((resolve, reject) => { let dispose; popup(import('@client/components/ui/popup-menu.vue'), { items, src, + width: options?.width, align: options?.align, viaKeyboard: options?.viaKeyboard }, { diff --git a/src/client/pages/instance/abuses.vue b/src/client/pages/admin/abuses.vue index 29da8cc2c5..29da8cc2c5 100644 --- a/src/client/pages/instance/abuses.vue +++ b/src/client/pages/admin/abuses.vue diff --git a/src/client/pages/instance/ads.vue b/src/client/pages/admin/ads.vue index e776f99a4c..e776f99a4c 100644 --- a/src/client/pages/instance/ads.vue +++ b/src/client/pages/admin/ads.vue diff --git a/src/client/pages/instance/announcements.vue b/src/client/pages/admin/announcements.vue index 78637c095a..78637c095a 100644 --- a/src/client/pages/instance/announcements.vue +++ b/src/client/pages/admin/announcements.vue diff --git a/src/client/pages/instance/bot-protection.vue b/src/client/pages/admin/bot-protection.vue index 731f114cc2..731f114cc2 100644 --- a/src/client/pages/instance/bot-protection.vue +++ b/src/client/pages/admin/bot-protection.vue diff --git a/src/client/pages/instance/database.vue b/src/client/pages/admin/database.vue index ffbeed8b30..ffbeed8b30 100644 --- a/src/client/pages/instance/database.vue +++ b/src/client/pages/admin/database.vue diff --git a/src/client/pages/instance/email-settings.vue b/src/client/pages/admin/email-settings.vue index ebf724fcdd..ebf724fcdd 100644 --- a/src/client/pages/instance/email-settings.vue +++ b/src/client/pages/admin/email-settings.vue diff --git a/src/client/pages/instance/emoji-edit-dialog.vue b/src/client/pages/admin/emoji-edit-dialog.vue index 4854c69884..4854c69884 100644 --- a/src/client/pages/instance/emoji-edit-dialog.vue +++ b/src/client/pages/admin/emoji-edit-dialog.vue diff --git a/src/client/pages/instance/emojis.vue b/src/client/pages/admin/emojis.vue index 4cd34b046d..4cd34b046d 100644 --- a/src/client/pages/instance/emojis.vue +++ b/src/client/pages/admin/emojis.vue diff --git a/src/client/pages/instance/file-dialog.vue b/src/client/pages/admin/file-dialog.vue index 02d83e5022..02d83e5022 100644 --- a/src/client/pages/instance/file-dialog.vue +++ b/src/client/pages/admin/file-dialog.vue diff --git a/src/client/pages/instance/files-settings.vue b/src/client/pages/admin/files-settings.vue index 8aefa9e90d..8aefa9e90d 100644 --- a/src/client/pages/instance/files-settings.vue +++ b/src/client/pages/admin/files-settings.vue diff --git a/src/client/pages/instance/files.vue b/src/client/pages/admin/files.vue index 55189cfd84..55189cfd84 100644 --- a/src/client/pages/instance/files.vue +++ b/src/client/pages/admin/files.vue diff --git a/src/client/pages/instance/index.vue b/src/client/pages/admin/index.vue index 7b07bf2dde..416e68206c 100644 --- a/src/client/pages/instance/index.vue +++ b/src/client/pages/admin/index.vue @@ -7,8 +7,8 @@ <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> </div> - <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/instance/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> - <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/instance/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo> <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu> </div> @@ -93,47 +93,47 @@ export default defineComponent({ items: [{ icon: 'fas fa-tachometer-alt', text: i18n.locale.dashboard, - to: '/instance/overview', + to: '/admin/overview', active: page.value === 'overview', }, { icon: 'fas fa-users', text: i18n.locale.users, - to: '/instance/users', + to: '/admin/users', active: page.value === 'users', }, { icon: 'fas fa-laugh', text: i18n.locale.customEmojis, - to: '/instance/emojis', + to: '/admin/emojis', active: page.value === 'emojis', }, { icon: 'fas fa-globe', text: i18n.locale.federation, - to: '/instance/federation', + to: '/admin/federation', active: page.value === 'federation', }, { icon: 'fas fa-clipboard-list', text: i18n.locale.jobQueue, - to: '/instance/queue', + to: '/admin/queue', active: page.value === 'queue', }, { icon: 'fas fa-cloud', text: i18n.locale.files, - to: '/instance/files', + to: '/admin/files', active: page.value === 'files', }, { icon: 'fas fa-broadcast-tower', text: i18n.locale.announcements, - to: '/instance/announcements', + to: '/admin/announcements', active: page.value === 'announcements', }, { icon: 'fas fa-audio-description', text: i18n.locale.ads, - to: '/instance/ads', + to: '/admin/ads', active: page.value === 'ads', }, { icon: 'fas fa-exclamation-circle', text: i18n.locale.abuseReports, - to: '/instance/abuses', + to: '/admin/abuses', active: page.value === 'abuses', }], }, { @@ -141,57 +141,57 @@ export default defineComponent({ items: [{ icon: 'fas fa-cog', text: i18n.locale.general, - to: '/instance/settings', + to: '/admin/settings', active: page.value === 'settings', }, { icon: 'fas fa-cloud', text: i18n.locale.files, - to: '/instance/files-settings', + to: '/admin/files-settings', active: page.value === 'files-settings', }, { icon: 'fas fa-envelope', text: i18n.locale.emailServer, - to: '/instance/email-settings', + to: '/admin/email-settings', active: page.value === 'email-settings', }, { icon: 'fas fa-cloud', text: i18n.locale.objectStorage, - to: '/instance/object-storage', + to: '/admin/object-storage', active: page.value === 'object-storage', }, { icon: 'fas fa-lock', text: i18n.locale.security, - to: '/instance/security', + to: '/admin/security', active: page.value === 'security', }, { icon: 'fas fa-bolt', text: 'ServiceWorker', - to: '/instance/service-worker', + to: '/admin/service-worker', active: page.value === 'service-worker', }, { icon: 'fas fa-globe', text: i18n.locale.relays, - to: '/instance/relays', + to: '/admin/relays', active: page.value === 'relays', }, { icon: 'fas fa-share-alt', text: i18n.locale.integration, - to: '/instance/integrations', + to: '/admin/integrations', active: page.value === 'integrations', }, { icon: 'fas fa-ban', text: i18n.locale.instanceBlocking, - to: '/instance/instance-block', + to: '/admin/instance-block', active: page.value === 'instance-block', }, { icon: 'fas fa-ghost', text: i18n.locale.proxyAccount, - to: '/instance/proxy-account', + to: '/admin/proxy-account', active: page.value === 'proxy-account', }, { icon: 'fas fa-cogs', text: i18n.locale.other, - to: '/instance/other-settings', + to: '/admin/other-settings', active: page.value === 'other-settings', }], }, { @@ -199,13 +199,8 @@ export default defineComponent({ items: [{ icon: 'fas fa-database', text: i18n.locale.database, - to: '/instance/database', + to: '/admin/database', active: page.value === 'database', - }, { - icon: 'fas fa-stream', - text: i18n.locale.logs, - to: '/instance/logs', - active: page.value === 'logs', }], }]); const component = computed(() => { @@ -220,7 +215,6 @@ export default defineComponent({ case 'announcements': return defineAsyncComponent(() => import('./announcements.vue')); case 'ads': return defineAsyncComponent(() => import('./ads.vue')); case 'database': return defineAsyncComponent(() => import('./database.vue')); - case 'logs': return defineAsyncComponent(() => import('./logs.vue')); case 'abuses': return defineAsyncComponent(() => import('./abuses.vue')); case 'settings': return defineAsyncComponent(() => import('./settings.vue')); case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue')); diff --git a/src/client/pages/instance/instance-block.vue b/src/client/pages/admin/instance-block.vue index 105cdb4941..105cdb4941 100644 --- a/src/client/pages/instance/instance-block.vue +++ b/src/client/pages/admin/instance-block.vue diff --git a/src/client/pages/instance/instance.vue b/src/client/pages/admin/instance.vue index 6117f090de..5572fbbf75 100644 --- a/src/client/pages/instance/instance.vue +++ b/src/client/pages/admin/instance.vue @@ -78,17 +78,17 @@ <span class="label">{{ $ts.charts }}</span> <div class="selects"> <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <option value="requests">{{ $ts._instanceCharts.requests }}</option> - <option value="users">{{ $ts._instanceCharts.users }}</option> - <option value="users-total">{{ $ts._instanceCharts.usersTotal }}</option> - <option value="notes">{{ $ts._instanceCharts.notes }}</option> - <option value="notes-total">{{ $ts._instanceCharts.notesTotal }}</option> - <option value="ff">{{ $ts._instanceCharts.ff }}</option> - <option value="ff-total">{{ $ts._instanceCharts.ffTotal }}</option> - <option value="drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> - <option value="drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> - <option value="drive-files">{{ $ts._instanceCharts.files }}</option> - <option value="drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ $ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> </MkSelect> <MkSelect v-model="chartSpan" style="margin: 0;"> <option value="hour">{{ $ts.perHour }}</option> @@ -97,7 +97,7 @@ </div> </div> <div class="chart"> - <canvas :ref="setChart"></canvas> + <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart> </div> </div> <div class="operations section"> @@ -124,28 +124,17 @@ <script lang="ts"> import { defineComponent, markRaw } from 'vue'; -import Chart from 'chart.js'; import XModalWindow from '@client/components/ui/modal-window.vue'; import MkUsersDialog from '@client/components/users-dialog.vue'; import MkSelect from '@client/components/form/select.vue'; import MkButton from '@client/components/ui/button.vue'; import MkSwitch from '@client/components/form/switch.vue'; import MkInfo from '@client/components/ui/info.vue'; +import MkChart from '@client/components/chart.vue'; import bytes from '@client/filters/bytes'; import number from '@client/filters/number'; import * as os from '@client/os'; -const chartLimit = 90; -const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); -const negate = arr => arr.map(x => -x); -const alpha = hex => { - 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}, 0.1)`; -}; - export default defineComponent({ components: { XModalWindow, @@ -153,6 +142,7 @@ export default defineComponent({ MkButton, MkSwitch, MkInfo, + MkChart, }, props: { @@ -167,42 +157,12 @@ export default defineComponent({ data() { return { isSuspended: this.instance.isSuspended, - now: null, - canvas: null, - chart: null, - chartInstance: null, chartSrc: 'requests', chartSpan: 'hour', }; }, computed: { - data(): any { - if (this.chart == null) return null; - switch (this.chartSrc) { - case 'requests': return this.requestsChart(); - case 'users': return this.usersChart(false); - case 'users-total': return this.usersChart(true); - case 'notes': return this.notesChart(false); - case 'notes-total': return this.notesChart(true); - case 'ff': return this.ffChart(false); - case 'ff-total': return this.ffChart(true); - case 'drive-usage': return this.driveUsageChart(false); - case 'drive-usage-total': return this.driveUsageChart(true); - case 'drive-files': return this.driveFilesChart(false); - case 'drive-files-total': return this.driveFilesChart(true); - } - }, - - stats(): any[] { - const stats = - this.chartSpan == 'day' ? this.chart.perDay : - this.chartSpan == 'hour' ? this.chart.perHour : - null; - - return stats; - }, - meta() { return this.$instance; }, @@ -219,49 +179,15 @@ export default defineComponent({ isSuspended: this.isSuspended }); }, - - chartSrc() { - this.renderChart(); - }, - - chartSpan() { - this.renderChart(); - } - }, - - async created() { - this.now = new Date(); - - const [perHour, perDay] = await Promise.all([ - os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), - os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), - ]); - - const chart = { - perHour: perHour, - perDay: perDay - }; - - this.chart = chart; - - this.renderChart(); }, methods: { - setChart(el) { - this.canvas = el; - }, - changeBlock(e) { os.api('admin/update-meta', { blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host) }); }, - setSrc(src) { - this.chartSrc = src; - }, - removeAllFollowing() { os.apiWithDialog('admin/federation/remove-all-following', { host: this.instance.host @@ -274,170 +200,6 @@ export default defineComponent({ }); }, - renderChart() { - if (this.chartInstance) { - this.chartInstance.destroy(); - } - - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - this.chartInstance = markRaw(new Chart(this.canvas, { - type: 'line', - data: { - labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), - datasets: this.data.series.map(x => ({ - label: x.name, - data: x.data.slice().reverse(), - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: x.color, - backgroundColor: alpha(x.color), - })) - }, - options: { - aspectRatio: 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 0 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - gridLines: { - display: false - }, - ticks: { - display: false - } - }], - yAxes: [{ - position: 'right', - ticks: { - display: false - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - })); - }, - - getDate(ago: number) { - const y = this.now.getFullYear(); - const m = this.now.getMonth(); - const d = this.now.getDate(); - const h = this.now.getHours(); - - return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); - }, - - format(arr) { - return arr; - }, - - requestsChart(): any { - return { - series: [{ - name: 'In', - color: '#008FFB', - data: this.format(this.stats.requests.received) - }, { - name: 'Out (succ)', - color: '#00E396', - data: this.format(this.stats.requests.succeeded) - }, { - name: 'Out (fail)', - color: '#FEB019', - data: this.format(this.stats.requests.failed) - }] - }; - }, - - usersChart(total: boolean): any { - return { - series: [{ - name: 'Users', - color: '#008FFB', - data: this.format(total - ? this.stats.users.total - : sum(this.stats.users.inc, negate(this.stats.users.dec)) - ) - }] - }; - }, - - notesChart(total: boolean): any { - return { - series: [{ - name: 'Notes', - color: '#008FFB', - data: this.format(total - ? this.stats.notes.total - : sum(this.stats.notes.inc, negate(this.stats.notes.dec)) - ) - }] - }; - }, - - ffChart(total: boolean): any { - return { - series: [{ - name: 'Following', - color: '#008FFB', - data: this.format(total - ? this.stats.following.total - : sum(this.stats.following.inc, negate(this.stats.following.dec)) - ) - }, { - name: 'Followers', - color: '#00E396', - data: this.format(total - ? this.stats.followers.total - : sum(this.stats.followers.inc, negate(this.stats.followers.dec)) - ) - }] - }; - }, - - driveUsageChart(total: boolean): any { - return { - bytes: true, - series: [{ - name: 'Drive usage', - color: '#008FFB', - data: this.format(total - ? this.stats.drive.totalUsage - : sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage)) - ) - }] - }; - }, - - driveFilesChart(total: boolean): any { - return { - series: [{ - name: 'Drive files', - color: '#008FFB', - data: this.format(total - ? this.stats.drive.totalFiles - : sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles)) - ) - }] - }; - }, - showFollowing() { os.modal(MkUsersDialog, { title: this.$ts.instanceFollowing, diff --git a/src/client/pages/instance/integrations-discord.vue b/src/client/pages/admin/integrations-discord.vue index c33b24f17f..c33b24f17f 100644 --- a/src/client/pages/instance/integrations-discord.vue +++ b/src/client/pages/admin/integrations-discord.vue diff --git a/src/client/pages/instance/integrations-github.vue b/src/client/pages/admin/integrations-github.vue index cdf85868ff..cdf85868ff 100644 --- a/src/client/pages/instance/integrations-github.vue +++ b/src/client/pages/admin/integrations-github.vue diff --git a/src/client/pages/instance/integrations-twitter.vue b/src/client/pages/admin/integrations-twitter.vue index ed7d097d0a..ed7d097d0a 100644 --- a/src/client/pages/instance/integrations-twitter.vue +++ b/src/client/pages/admin/integrations-twitter.vue diff --git a/src/client/pages/instance/integrations.vue b/src/client/pages/admin/integrations.vue index 6964ae5704..bdc2cec4d0 100644 --- a/src/client/pages/instance/integrations.vue +++ b/src/client/pages/admin/integrations.vue @@ -1,15 +1,15 @@ <template> <FormBase> <FormSuspense :p="init"> - <FormLink to="/instance/integrations/twitter"> + <FormLink to="/admin/integrations/twitter"> <i class="fab fa-twitter"></i> Twitter <template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template> </FormLink> - <FormLink to="/instance/integrations/github"> + <FormLink to="/admin/integrations/github"> <i class="fab fa-github"></i> GitHub <template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template> </FormLink> - <FormLink to="/instance/integrations/discord"> + <FormLink to="/admin/integrations/discord"> <i class="fab fa-discord"></i> Discord <template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template> </FormLink> diff --git a/src/client/pages/instance/metrics.vue b/src/client/pages/admin/metrics.vue index 1606063aee..da36f6c688 100644 --- a/src/client/pages/instance/metrics.vue +++ b/src/client/pages/admin/metrics.vue @@ -52,7 +52,21 @@ <script lang="ts"> import { defineComponent, markRaw } from 'vue'; -import Chart from 'chart.js'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle +} from 'chart.js'; import MkButton from '@client/components/ui/button.vue'; import MkSelect from '@client/components/form/select.vue'; import MkInput from '@client/components/form/input.vue'; @@ -64,6 +78,21 @@ import bytes from '@client/filters/bytes'; import number from '@client/filters/number'; import MkInstanceInfo from './instance.vue'; +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle +); + const alpha = (hex, a) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; const r = parseInt(result[1], 16); @@ -116,7 +145,7 @@ export default defineComponent({ mounted() { this.fetchJobs(); - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); os.api('admin/server-info', {}).then(res => { this.serverInfo = res; @@ -157,7 +186,7 @@ export default defineComponent({ datasets: [{ label: 'CPU', pointRadius: 0, - lineTension: 0, + tension: 0, borderWidth: 2, borderColor: '#86b300', backgroundColor: alpha('#86b300', 0.1), @@ -165,7 +194,7 @@ export default defineComponent({ }, { label: 'MEM (active)', pointRadius: 0, - lineTension: 0, + tension: 0, borderWidth: 2, borderColor: '#935dbf', backgroundColor: alpha('#935dbf', 0.02), @@ -173,7 +202,7 @@ export default defineComponent({ }, { label: 'MEM (used)', pointRadius: 0, - lineTension: 0, + tension: 0, borderWidth: 2, borderColor: '#935dbf', borderDash: [5, 5], @@ -198,7 +227,7 @@ export default defineComponent({ } }, scales: { - xAxes: [{ + x: { gridLines: { display: false, color: this.gridColor, @@ -207,8 +236,8 @@ export default defineComponent({ ticks: { display: false, } - }], - yAxes: [{ + }, + y: { position: 'right', gridLines: { display: true, @@ -219,7 +248,7 @@ export default defineComponent({ display: false, max: 100 } - }] + } }, tooltips: { intersect: false, @@ -238,7 +267,7 @@ export default defineComponent({ datasets: [{ label: 'In', pointRadius: 0, - lineTension: 0, + tension: 0, borderWidth: 2, borderColor: '#94a029', backgroundColor: alpha('#94a029', 0.1), @@ -246,7 +275,7 @@ export default defineComponent({ }, { label: 'Out', pointRadius: 0, - lineTension: 0, + tension: 0, borderWidth: 2, borderColor: '#ff9156', backgroundColor: alpha('#ff9156', 0.1), @@ -270,7 +299,7 @@ export default defineComponent({ } }, scales: { - xAxes: [{ + x: { gridLines: { display: false, color: this.gridColor, @@ -279,8 +308,8 @@ export default defineComponent({ ticks: { display: false } - }], - yAxes: [{ + }, + y: { position: 'right', gridLines: { display: true, @@ -290,7 +319,7 @@ export default defineComponent({ ticks: { display: false, } - }] + } }, tooltips: { intersect: false, @@ -309,7 +338,7 @@ export default defineComponent({ datasets: [{ label: 'Read', pointRadius: 0, - lineTension: 0, + tension: 0, borderWidth: 2, borderColor: '#94a029', backgroundColor: alpha('#94a029', 0.1), @@ -317,7 +346,7 @@ export default defineComponent({ }, { label: 'Write', pointRadius: 0, - lineTension: 0, + tension: 0, borderWidth: 2, borderColor: '#ff9156', backgroundColor: alpha('#ff9156', 0.1), @@ -341,7 +370,7 @@ export default defineComponent({ } }, scales: { - xAxes: [{ + x: { gridLines: { display: false, color: this.gridColor, @@ -350,8 +379,8 @@ export default defineComponent({ ticks: { display: false } - }], - yAxes: [{ + }, + y: { position: 'right', gridLines: { display: true, @@ -361,7 +390,7 @@ export default defineComponent({ ticks: { display: false, } - }] + } }, tooltips: { intersect: false, @@ -371,18 +400,6 @@ export default defineComponent({ })); }, - async showInstanceInfo(q) { - let instance = q; - if (typeof q === 'string') { - instance = await os.api('federation/show-instance', { - host: q - }); - } - os.popup(MkInstanceInfo, { - instance: instance - }, {}, 'closed'); - }, - fetchJobs() { os.api('admin/queue/deliver-delayed', {}).then(jobs => { this.jobs = jobs; diff --git a/src/client/pages/instance/object-storage.vue b/src/client/pages/admin/object-storage.vue index 2d765270e6..2d765270e6 100644 --- a/src/client/pages/instance/object-storage.vue +++ b/src/client/pages/admin/object-storage.vue diff --git a/src/client/pages/instance/other-settings.vue b/src/client/pages/admin/other-settings.vue index 4e55df41fb..4e55df41fb 100644 --- a/src/client/pages/instance/other-settings.vue +++ b/src/client/pages/admin/other-settings.vue diff --git a/src/client/pages/admin/overview.vue b/src/client/pages/admin/overview.vue new file mode 100644 index 0000000000..bb9c10f106 --- /dev/null +++ b/src/client/pages/admin/overview.vue @@ -0,0 +1,242 @@ +<template> +<div> + <MkHeader :info="header"/> + + <div class="edbbcaef" v-size="{ max: [880] }"> + <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)"> + <div class="number _panel"> + <div class="label">Users</div> + <div class="value _monospace"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-if="usersComparedToThePrevDay != null" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + <div class="number _panel"> + <div class="label">Notes</div> + <div class="value _monospace"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-if="notesComparedToThePrevDay != null" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + </div> + + <MkContainer :foldable="true" class="charts"> + <template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template> + <div style="padding-top: 12px;"> + <MkInstanceStats :chart-limit="500" :detailed="true"/> + </div> + </MkContainer> + + <div class="queue"> + <MkContainer :foldable="true" :thin="true" class="deliver"> + <template #header>Queue: deliver</template> + <MkQueueChart :connection="queueStatsConnection" domain="deliver"/> + </MkContainer> + <MkContainer :foldable="true" :thin="true" class="inbox"> + <template #header>Queue: inbox</template> + <MkQueueChart :connection="queueStatsConnection" domain="inbox"/> + </MkContainer> + </div> + + <!--<XMetrics/>--> + + <MkFolder style="margin: var(--margin)"> + <template #header><i class="fas fa-info-circle"></i> {{ $ts.info }}</template> + <div class="cfcdecdf"> + <div class="number _panel"> + <div class="label">Misskey</div> + <div class="value _monospace">{{ version }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">Node.js</div> + <div class="value _monospace">{{ serverInfo.node }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">PostgreSQL</div> + <div class="value _monospace">{{ serverInfo.psql }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">Redis</div> + <div class="value _monospace">{{ serverInfo.redis }}</div> + </div> + <div class="number _panel"> + <div class="label">Vue</div> + <div class="value _monospace">{{ vueVersion }}</div> + </div> + </div> + </MkFolder> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw, version as vueVersion } from 'vue'; +import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; +import MkInstanceStats from '@client/components/instance-stats.vue'; +import MkButton from '@client/components/ui/button.vue'; +import MkSelect from '@client/components/form/select.vue'; +import MkNumberDiff from '@client/components/number-diff.vue'; +import MkContainer from '@client/components/ui/container.vue'; +import MkFolder from '@client/components/ui/folder.vue'; +import MkQueueChart from '@client/components/queue-chart.vue'; +import { version, url } from '@client/config'; +import bytes from '@client/filters/bytes'; +import number from '@client/filters/number'; +import MkInstanceInfo from './instance.vue'; +import XMetrics from './metrics.vue'; +import * as os from '@client/os'; +import * as symbols from '@client/symbols'; + +export default defineComponent({ + components: { + MkNumberDiff, + FormKeyValueView, + MkInstanceStats, + MkContainer, + MkFolder, + MkQueueChart, + XMetrics, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.dashboard, + icon: 'fas fa-tachometer-alt', + bg: 'var(--bg)', + }, + header: { + title: this.$ts.dashboard, + icon: 'fas fa-tachometer-alt', + }, + version, + vueVersion, + url, + stats: null, + meta: null, + serverInfo: null, + usersComparedToThePrevDay: null, + notesComparedToThePrevDay: null, + fetchJobs: () => os.api('admin/queue/deliver-delayed', {}), + fetchModLogs: () => os.api('admin/show-moderation-logs', {}), + queueStatsConnection: markRaw(os.stream.useChannel('queueStats')), + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats', {}).then(stats => { + this.stats = stats; + + os.api('charts/users', { limit: 2, span: 'day' }).then(chart => { + this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1]; + }); + + os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => { + this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1]; + }); + }); + + os.api('admin/server-info', {}).then(serverInfo => { + this.serverInfo = serverInfo; + }); + + this.$nextTick(() => { + this.queueStatsConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }, + + beforeUnmount() { + this.queueStatsConnection.dispose(); + }, + + methods: { + async showInstanceInfo(q) { + let instance = q; + if (typeof q === 'string') { + instance = await os.api('federation/show-instance', { + host: q + }); + } + os.popup(MkInstanceInfo, { + instance: instance + }, {}, 'closed'); + }, + + bytes, + + number, + } +}); +</script> + +<style lang="scss" scoped> +.edbbcaef { + .cfcdecdf { + display: grid; + grid-gap: 8px; + grid-template-columns: repeat(auto-fill,minmax(150px,1fr)); + + > .number { + padding: 12px 16px; + + > .label { + opacity: 0.7; + font-size: 0.8em; + } + + > .value { + font-weight: bold; + font-size: 1.2em; + + > .diff { + font-size: 0.8em; + } + } + } + } + + > .charts { + margin: var(--margin); + } + + > .queue { + margin: var(--margin); + display: flex; + + > .deliver, + > .inbox { + flex: 1; + width: 50%; + + &:not(:first-child) { + margin-left: var(--margin); + } + } + } + + &.max-width_800px { + > .queue { + display: block; + + > .deliver, + > .inbox { + &:not(:first-child) { + margin-top: var(--margin); + margin-left: 0; + } + } + } + } +} +</style> diff --git a/src/client/pages/instance/proxy-account.vue b/src/client/pages/admin/proxy-account.vue index b1ece19710..b1ece19710 100644 --- a/src/client/pages/instance/proxy-account.vue +++ b/src/client/pages/admin/proxy-account.vue diff --git a/src/client/pages/admin/queue.chart.vue b/src/client/pages/admin/queue.chart.vue new file mode 100644 index 0000000000..084181a606 --- /dev/null +++ b/src/client/pages/admin/queue.chart.vue @@ -0,0 +1,102 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><slot name="title"></slot></div> + <div class="_debobigegoPanel pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class=""> + <MkQueueChart :domain="domain" :connection="connection"/> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> + </div> + </div> + <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw, onMounted, onUnmounted, ref } from 'vue'; +import number from '@client/filters/number'; +import MkQueueChart from '@client/components/queue-chart.vue'; +import * as os from '@client/os'; + +export default defineComponent({ + components: { + MkQueueChart + }, + + props: { + domain: { + type: String, + required: true, + }, + connection: { + required: true, + }, + }, + + setup(props) { + const activeSincePrevTick = ref(0); + const active = ref(0); + const waiting = ref(0); + const delayed = ref(0); + const jobs = ref([]); + + onMounted(() => { + os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => { + jobs.value = jobs; + }); + + const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + waiting.value = stats[props.domain].waiting; + delayed.value = stats[props.domain].delayed; + }; + + props.connection.on('stats', onStats); + + onUnmounted(() => { + props.connection.off('stats', onStats); + }); + }); + + return { + jobs, + activeSincePrevTick, + active, + waiting, + delayed, + number, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.pumxzjhg { + > .status { + padding: 16px; + border-bottom: solid 0.5px var(--divider); + } + + > .jobs { + padding: 16px; + border-top: solid 0.5px var(--divider); + max-height: 180px; + overflow: auto; + } +} +</style> diff --git a/src/client/pages/instance/queue.vue b/src/client/pages/admin/queue.vue index f88825eb19..f88825eb19 100644 --- a/src/client/pages/instance/queue.vue +++ b/src/client/pages/admin/queue.vue diff --git a/src/client/pages/instance/relays.vue b/src/client/pages/admin/relays.vue index 7d7888eaa8..7d7888eaa8 100644 --- a/src/client/pages/instance/relays.vue +++ b/src/client/pages/admin/relays.vue diff --git a/src/client/pages/instance/security.vue b/src/client/pages/admin/security.vue index a854b6dbd0..4365b6800c 100644 --- a/src/client/pages/instance/security.vue +++ b/src/client/pages/admin/security.vue @@ -1,7 +1,7 @@ <template> <FormBase> <FormSuspense :p="init"> - <FormLink to="/instance/bot-protection"> + <FormLink to="/admin/bot-protection"> <i class="fas fa-shield-alt"></i> {{ $ts.botProtection }} <template #suffix v-if="enableHcaptcha">hCaptcha</template> <template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template> diff --git a/src/client/pages/instance/service-worker.vue b/src/client/pages/admin/service-worker.vue index 430e02ad2e..430e02ad2e 100644 --- a/src/client/pages/instance/service-worker.vue +++ b/src/client/pages/admin/service-worker.vue diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/admin/settings.vue index 7bd363e5f3..7bd363e5f3 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/admin/settings.vue diff --git a/src/client/pages/instance/users.vue b/src/client/pages/admin/users.vue index f7f9306b70..f7f9306b70 100644 --- a/src/client/pages/instance/users.vue +++ b/src/client/pages/admin/users.vue diff --git a/src/client/pages/explore.vue b/src/client/pages/explore.vue index 2ca0668611..6f304877b7 100644 --- a/src/client/pages/explore.vue +++ b/src/client/pages/explore.vue @@ -65,13 +65,18 @@ </div> <div v-else-if="tab === 'search'"> <div class="_isolated"> - <MkInput v-model="query" :debounce="true" type="search"> + <MkInput v-model="searchQuery" :debounce="true" type="search"> <template #prefix><i class="fas fa-search"></i></template> <template #label>{{ $ts.searchUser }}</template> </MkInput> + <MkRadios v-model="searchOrigin"> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + <option value="both">{{ $ts.both }}</option> + </MkRadios> </div> - <XUserList v-if="query" class="_gap" :pagination="searchPagination" ref="search"/> + <XUserList v-if="searchQuery" class="_gap" :pagination="searchPagination" ref="search"/> </div> </div> </MkSpacer> @@ -83,6 +88,7 @@ import { computed, defineComponent } from 'vue'; import XUserList from '@client/components/user-list.vue'; import MkFolder from '@client/components/ui/folder.vue'; import MkInput from '@client/components/form/input.vue'; +import MkRadios from '@client/components/form/radios.vue'; import number from '@client/filters/number'; import * as os from '@client/os'; import * as symbols from '@client/symbols'; @@ -92,6 +98,7 @@ export default defineComponent({ XUserList, MkFolder, MkInput, + MkRadios, }, props: { @@ -158,14 +165,16 @@ export default defineComponent({ searchPagination: { endpoint: 'users/search', limit: 10, - params: computed(() => (this.query && this.query !== '') ? { - query: this.query + params: computed(() => (this.searchQuery && this.searchQuery !== '') ? { + query: this.searchQuery, + origin: this.searchOrigin, } : null) }, tagsLocal: [], tagsRemote: [], stats: null, - query: null, + searchQuery: null, + searchOrigin: 'combined', num: number, }; }, diff --git a/src/client/pages/instance-info.vue b/src/client/pages/instance-info.vue index 4fbf104f0c..291ceb5dfd 100644 --- a/src/client/pages/instance-info.vue +++ b/src/client/pages/instance-info.vue @@ -65,17 +65,17 @@ <div class="_debobigegoPanel cmhjzshl"> <div class="selects"> <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <option value="requests">{{ $ts._instanceCharts.requests }}</option> - <option value="users">{{ $ts._instanceCharts.users }}</option> - <option value="users-total">{{ $ts._instanceCharts.usersTotal }}</option> - <option value="notes">{{ $ts._instanceCharts.notes }}</option> - <option value="notes-total">{{ $ts._instanceCharts.notesTotal }}</option> - <option value="ff">{{ $ts._instanceCharts.ff }}</option> - <option value="ff-total">{{ $ts._instanceCharts.ffTotal }}</option> - <option value="drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> - <option value="drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> - <option value="drive-files">{{ $ts._instanceCharts.files }}</option> - <option value="drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ $ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> </MkSelect> <MkSelect v-model="chartSpan" style="margin: 0;"> <option value="hour">{{ $ts.perHour }}</option> @@ -83,7 +83,7 @@ </MkSelect> </div> <div class="chart"> - <canvas :ref="setChart"></canvas> + <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart> </div> </div> </div> @@ -135,7 +135,7 @@ <script lang="ts"> import { defineAsyncComponent, defineComponent } from 'vue'; -import Chart from 'chart.js'; +import MkChart from '@client/components/chart.vue'; import FormObjectView from '@client/components/debobigego/object-view.vue'; import FormTextarea from '@client/components/debobigego/textarea.vue'; import FormLink from '@client/components/debobigego/link.vue'; @@ -149,18 +149,7 @@ import * as os from '@client/os'; import number from '@client/filters/number'; import bytes from '@client/filters/bytes'; import * as symbols from '@client/symbols'; -import MkInstanceInfo from '@client/pages/instance/instance.vue'; - -const chartLimit = 90; -const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); -const negate = arr => arr.map(x => -x); -const alpha = hex => { - 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}, 0.1)`; -}; +import MkInstanceInfo from '@client/pages/admin/instance.vue'; export default defineComponent({ components: { @@ -173,6 +162,7 @@ export default defineComponent({ FormKeyValueView, FormSuspense, MkSelect, + MkChart, }, props: { @@ -199,53 +189,11 @@ export default defineComponent({ dnsPromiseFactory: () => os.api('federation/dns', { host: this.host }), - now: null, - canvas: null, - chart: null, - chartInstance: null, - chartSrc: 'requests', + chartSrc: 'instance-requests', chartSpan: 'hour', } }, - computed: { - data(): any { - if (this.chart == null) return null; - switch (this.chartSrc) { - case 'requests': return this.requestsChart(); - case 'users': return this.usersChart(false); - case 'users-total': return this.usersChart(true); - case 'notes': return this.notesChart(false); - case 'notes-total': return this.notesChart(true); - case 'ff': return this.ffChart(false); - case 'ff-total': return this.ffChart(true); - case 'drive-usage': return this.driveUsageChart(false); - case 'drive-usage-total': return this.driveUsageChart(true); - case 'drive-files': return this.driveFilesChart(false); - case 'drive-files-total': return this.driveFilesChart(true); - } - }, - - stats(): any[] { - const stats = - this.chartSpan == 'day' ? this.chart.perDay : - this.chartSpan == 'hour' ? this.chart.perHour : - null; - - return stats; - }, - }, - - watch: { - chartSrc() { - this.renderChart(); - }, - - chartSpan() { - this.renderChart(); - } - }, - mounted() { this.fetch(); }, @@ -258,190 +206,6 @@ export default defineComponent({ this.instance = await os.api('federation/show-instance', { host: this.host }); - - this.now = new Date(); - - const [perHour, perDay] = await Promise.all([ - os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'hour' }), - os.api('charts/instance', { host: this.instance.host, limit: chartLimit, span: 'day' }), - ]); - - const chart = { - perHour: perHour, - perDay: perDay - }; - - this.chart = chart; - - this.renderChart(); - }, - - setChart(el) { - this.canvas = el; - }, - - renderChart() { - if (this.chartInstance) { - this.chartInstance.destroy(); - } - - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - this.chartInstance = new Chart(this.canvas, { - type: 'line', - data: { - labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(), - datasets: this.data.series.map(x => ({ - label: x.name, - data: x.data.slice().reverse(), - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: x.color, - backgroundColor: alpha(x.color), - })) - }, - options: { - aspectRatio: 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 16 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - gridLines: { - display: false - }, - ticks: { - display: false - } - }], - yAxes: [{ - position: 'right', - ticks: { - display: false - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - }); - }, - - getDate(ago: number) { - const y = this.now.getFullYear(); - const m = this.now.getMonth(); - const d = this.now.getDate(); - const h = this.now.getHours(); - - return this.chartSpan == 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); - }, - - format(arr) { - return arr; - }, - - requestsChart(): any { - return { - series: [{ - name: 'In', - color: '#008FFB', - data: this.format(this.stats.requests.received) - }, { - name: 'Out (succ)', - color: '#00E396', - data: this.format(this.stats.requests.succeeded) - }, { - name: 'Out (fail)', - color: '#FEB019', - data: this.format(this.stats.requests.failed) - }] - }; - }, - - usersChart(total: boolean): any { - return { - series: [{ - name: 'Users', - color: '#008FFB', - data: this.format(total - ? this.stats.users.total - : sum(this.stats.users.inc, negate(this.stats.users.dec)) - ) - }] - }; - }, - - notesChart(total: boolean): any { - return { - series: [{ - name: 'Notes', - color: '#008FFB', - data: this.format(total - ? this.stats.notes.total - : sum(this.stats.notes.inc, negate(this.stats.notes.dec)) - ) - }] - }; - }, - - ffChart(total: boolean): any { - return { - series: [{ - name: 'Following', - color: '#008FFB', - data: this.format(total - ? this.stats.following.total - : sum(this.stats.following.inc, negate(this.stats.following.dec)) - ) - }, { - name: 'Followers', - color: '#00E396', - data: this.format(total - ? this.stats.followers.total - : sum(this.stats.followers.inc, negate(this.stats.followers.dec)) - ) - }] - }; - }, - - driveUsageChart(total: boolean): any { - return { - bytes: true, - series: [{ - name: 'Drive usage', - color: '#008FFB', - data: this.format(total - ? this.stats.drive.totalUsage - : sum(this.stats.drive.incUsage, negate(this.stats.drive.decUsage)) - ) - }] - }; - }, - - driveFilesChart(total: boolean): any { - return { - series: [{ - name: 'Drive files', - color: '#008FFB', - data: this.format(total - ? this.stats.drive.totalFiles - : sum(this.stats.drive.incFiles, negate(this.stats.drive.decFiles)) - ) - }] - }; }, info() { diff --git a/src/client/pages/instance/logs.vue b/src/client/pages/instance/logs.vue deleted file mode 100644 index 74aea0fc45..0000000000 --- a/src/client/pages/instance/logs.vue +++ /dev/null @@ -1,97 +0,0 @@ -<template> -<div class="_section"> - <div class="_inputs"> - <MkInput v-model="logDomain" :debounce="true"> - <template #label>{{ $ts.domain }}</template> - </MkInput> - <MkSelect v-model="logLevel"> - <template #label>Level</template> - <option value="all">All</option> - <option value="info">Info</option> - <option value="success">Success</option> - <option value="warning">Warning</option> - <option value="error">Error</option> - <option value="debug">Debug</option> - </MkSelect> - </div> - - <div class="logs"> - <code v-for="log in logs" :key="log.id" :class="log.level"> - <details> - <summary><MkTime :time="log.createdAt"/> [{{ log.domain.join('.') }}] {{ log.message }}</summary> - <!--<vue-json-pretty v-if="log.data" :data="log.data"></vue-json-pretty>--> - </details> - </code> - </div> - - <MkButton @click="deleteAllLogs()" primary><i class="fas fa-trash-alt"></i> {{ $ts.deleteAll }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - MkButton, - MkInput, - MkSelect, - MkTextarea, - }, - - emits: ['info'], - - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.serverLogs, - icon: 'fas fa-stream' - }, - logs: [], - logLevel: 'all', - logDomain: '', - } - }, - - watch: { - logLevel() { - this.logs = []; - this.fetchLogs(); - }, - logDomain() { - this.logs = []; - this.fetchLogs(); - } - }, - - created() { - this.fetchLogs(); - }, - - mounted() { - this.$emit('info', this[symbols.PAGE_INFO]); - }, - - methods: { - fetchLogs() { - os.api('admin/logs', { - level: this.logLevel === 'all' ? null : this.logLevel, - domain: this.logDomain === '' ? null : this.logDomain, - limit: 30 - }).then(logs => { - this.logs = logs.reverse(); - }); - }, - - deleteAllLogs() { - os.apiWithDialog('admin/delete-logs'); - }, - } -}); -</script> diff --git a/src/client/pages/instance/overview.vue b/src/client/pages/instance/overview.vue deleted file mode 100644 index c6db9d0c04..0000000000 --- a/src/client/pages/instance/overview.vue +++ /dev/null @@ -1,127 +0,0 @@ -<template> -<FormBase> - <FormSuspense :p="init"> - <FormSuspense :p="fetchStats" v-slot="{ result: stats }"> - <FormGroup> - <FormKeyValueView> - <template #key>Users</template> - <template #value>{{ number(stats.originalUsersCount) }}</template> - </FormKeyValueView> - <FormKeyValueView> - <template #key>Notes</template> - <template #value>{{ number(stats.originalNotesCount) }}</template> - </FormKeyValueView> - </FormGroup> - </FormSuspense> - - <div class="_debobigegoItem"> - <div class="_debobigegoPanel"> - <MkInstanceStats :chart-limit="300" :detailed="true"/> - </div> - </div> - - <XMetrics/> - - <FormSuspense :p="fetchServerInfo" v-slot="{ result: serverInfo }"> - <FormGroup> - <FormKeyValueView> - <template #key>Node.js</template> - <template #value>{{ serverInfo.node }}</template> - </FormKeyValueView> - <FormKeyValueView> - <template #key>PostgreSQL</template> - <template #value>{{ serverInfo.psql }}</template> - </FormKeyValueView> - <FormKeyValueView> - <template #key>Redis</template> - <template #value>{{ serverInfo.redis }}</template> - </FormKeyValueView> - </FormGroup> - </FormSuspense> - </FormSuspense> -</FormBase> -</template> - -<script lang="ts"> -import { computed, defineComponent, markRaw } from 'vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import MkInstanceStats from '@client/components/instance-stats.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import { version, url } from '@client/config'; -import bytes from '@client/filters/bytes'; -import number from '@client/filters/number'; -import MkInstanceInfo from './instance.vue'; -import XMetrics from './metrics.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - FormBase, - FormSuspense, - FormGroup, - FormInfo, - FormKeyValueView, - MkInstanceStats, - XMetrics, - }, - - emits: ['info'], - - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.overview, - icon: 'fas fa-tachometer-alt', - bg: 'var(--bg)', - }, - page: 'index', - version, - url, - stats: null, - meta: null, - fetchStats: () => os.api('stats', {}), - fetchServerInfo: () => os.api('admin/server-info', {}), - fetchJobs: () => os.api('admin/queue/deliver-delayed', {}), - fetchModLogs: () => os.api('admin/show-moderation-logs', {}), - } - }, - - async mounted() { - this.$emit('info', this[symbols.PAGE_INFO]); - }, - - methods: { - async init() { - this.meta = await os.api('meta', { detail: true }); - }, - - async showInstanceInfo(q) { - let instance = q; - if (typeof q === 'string') { - instance = await os.api('federation/show-instance', { - host: q - }); - } - os.popup(MkInstanceInfo, { - instance: instance - }, {}, 'closed'); - }, - - bytes, - - number, - } -}); -</script> diff --git a/src/client/pages/instance/queue.chart.vue b/src/client/pages/instance/queue.chart.vue deleted file mode 100644 index 887fe9a574..0000000000 --- a/src/client/pages/instance/queue.chart.vue +++ /dev/null @@ -1,218 +0,0 @@ -<template> -<div class="_debobigegoItem"> - <div class="_debobigegoLabel"><slot name="title"></slot></div> - <div class="_debobigegoPanel pumxzjhg"> - <div class="_table status"> - <div class="_row"> - <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> - <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> - <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> - <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> - </div> - </div> - <div class=""> - <canvas ref="chart"></canvas> - </div> - <div class="jobs"> - <div v-if="jobs.length > 0"> - <div v-for="job in jobs" :key="job[0]"> - <span>{{ job[0] }}</span> - <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> - </div> - </div> - <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import Chart from 'chart.js'; -import number from '@client/filters/number'; - -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 '@client/os'; - -export default defineComponent({ - props: { - domain: { - required: true - }, - connection: { - required: true - }, - }, - - data() { - return { - chart: null, - jobs: [], - activeSincePrevTick: 0, - active: 0, - waiting: 0, - delayed: 0, - } - }, - - mounted() { - this.fetchJobs(); - - // TODO: var(--panel)の色が暗いか明るいかで判定する - const gridColor = this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - this.chart = markRaw(new Chart(this.$refs.chart, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'Process', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#00E396', - backgroundColor: alpha('#00E396', 0.1), - data: [] - }, { - label: 'Active', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#00BCD4', - backgroundColor: alpha('#00BCD4', 0.1), - data: [] - }, { - label: 'Waiting', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#FFB300', - backgroundColor: alpha('#FFB300', 0.1), - data: [] - }, { - label: 'Delayed', - pointRadius: 0, - lineTension: 0, - borderWidth: 2, - borderColor: '#E53935', - borderDash: [5, 5], - fill: false, - data: [] - }] - }, - options: { - aspectRatio: 3, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 12 - } - }, - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - } - }, - scales: { - xAxes: [{ - gridLines: { - display: false, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false - } - }], - yAxes: [{ - position: 'right', - gridLines: { - display: true, - color: gridColor, - zeroLineColor: gridColor, - }, - ticks: { - display: false, - } - }] - }, - tooltips: { - intersect: false, - mode: 'index', - } - } - })); - - this.connection.on('stats', this.onStats); - this.connection.on('statsLog', this.onStatsLog); - }, - - beforeUnmount() { - this.connection.off('stats', this.onStats); - this.connection.off('statsLog', this.onStatsLog); - }, - - methods: { - onStats(stats) { - this.activeSincePrevTick = stats[this.domain].activeSincePrevTick; - this.active = stats[this.domain].active; - this.waiting = stats[this.domain].waiting; - this.delayed = stats[this.domain].delayed; - this.chart.data.labels.push(''); - this.chart.data.datasets[0].data.push(stats[this.domain].activeSincePrevTick); - this.chart.data.datasets[1].data.push(stats[this.domain].active); - this.chart.data.datasets[2].data.push(stats[this.domain].waiting); - this.chart.data.datasets[3].data.push(stats[this.domain].delayed); - if (this.chart.data.datasets[0].data.length > 200) { - this.chart.data.labels.shift(); - this.chart.data.datasets[0].data.shift(); - this.chart.data.datasets[1].data.shift(); - this.chart.data.datasets[2].data.shift(); - this.chart.data.datasets[3].data.shift(); - } - this.chart.update(); - }, - - onStatsLog(statsLog) { - for (const stats of [...statsLog].reverse()) { - this.onStats(stats); - } - }, - - fetchJobs() { - os.api(this.domain === 'inbox' ? 'admin/queue/inbox-delayed' : this.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(jobs => { - this.jobs = jobs; - }); - }, - - number - } -}); -</script> - -<style lang="scss" scoped> -.pumxzjhg { - > .status { - padding: 16px; - border-bottom: solid 0.5px var(--divider); - } - - > .jobs { - padding: 16px; - border-top: solid 0.5px var(--divider); - max-height: 180px; - overflow: auto; - } -} -</style> diff --git a/src/client/pages/settings/import-export.vue b/src/client/pages/settings/import-export.vue index 2b49996dda..eeaa1f1602 100644 --- a/src/client/pages/settings/import-export.vue +++ b/src/client/pages/settings/import-export.vue @@ -16,11 +16,13 @@ </FormSection> <FormSection> <template #label>{{ $ts._exportOrImport.muteList }}</template> - <MkButton :class="$style.button" inline @click="doExport('mute')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doExport('muting')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('muting', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> </FormSection> <FormSection> <template #label>{{ $ts._exportOrImport.blockingList }}</template> <MkButton :class="$style.button" inline @click="doExport('blocking')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('blocking', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> </FormSection> </div> </template> @@ -58,11 +60,11 @@ export default defineComponent({ methods: { doExport(target) { os.api( - target == 'notes' ? 'i/export-notes' : - target == 'following' ? 'i/export-following' : - target == 'blocking' ? 'i/export-blocking' : - target == 'user-lists' ? 'i/export-user-lists' : - target == 'mute' ? 'i/export-mute' : + target === 'notes' ? 'i/export-notes' : + target === 'following' ? 'i/export-following' : + target === 'blocking' ? 'i/export-blocking' : + target === 'user-lists' ? 'i/export-user-lists' : + target === 'muting' ? 'i/export-mute' : null, {}) .then(() => { os.dialog({ @@ -81,8 +83,10 @@ export default defineComponent({ const file = await selectFile(e.currentTarget || e.target); os.api( - target == 'following' ? 'i/import-following' : - target == 'user-lists' ? 'i/import-user-lists' : + target === 'following' ? 'i/import-following' : + target === 'user-lists' ? 'i/import-user-lists' : + target === 'muting' ? 'i/import-muting' : + target === 'blocking' ? 'i/import-blocking' : null, { fileId: file.id }).then(() => { diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue index 7756158578..2a60ae1f46 100644 --- a/src/client/pages/settings/privacy.vue +++ b/src/client/pages/settings/privacy.vue @@ -5,6 +5,10 @@ <FormSwitch v-model="autoAcceptFollowed" :disabled="!isLocked" @update:modelValue="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch> <template #caption>{{ $ts.lockedAccountInfo }}</template> </FormGroup> + <FormSwitch v-model="publicReactions" @update:modelValue="save()"> + {{ $ts.makeReactionsPublic }} + <template #desc>{{ $ts.makeReactionsPublicDescription }}</template> + </FormSwitch> <FormSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> {{ $ts.hideOnlineStatus }} <template #desc>{{ $ts.hideOnlineStatusDescription }}</template> @@ -64,6 +68,7 @@ export default defineComponent({ noCrawle: false, isExplorable: false, hideOnlineStatus: false, + publicReactions: false, } }, @@ -80,6 +85,7 @@ export default defineComponent({ this.noCrawle = this.$i.noCrawle; this.isExplorable = this.$i.isExplorable; this.hideOnlineStatus = this.$i.hideOnlineStatus; + this.publicReactions = this.$i.publicReactions; }, mounted() { @@ -94,6 +100,7 @@ export default defineComponent({ noCrawle: !!this.noCrawle, isExplorable: !!this.isExplorable, hideOnlineStatus: !!this.hideOnlineStatus, + publicReactions: !!this.publicReactions, }); } } diff --git a/src/client/pages/settings/theme.manage.vue b/src/client/pages/settings/theme.manage.vue index da21a47a50..1a11a664f0 100644 --- a/src/client/pages/settings/theme.manage.vue +++ b/src/client/pages/settings/theme.manage.vue @@ -10,13 +10,13 @@ </optgroup> </FormSelect> <template v-if="selectedTheme"> - <FormInput readonly :value="selectedTheme.author"> + <FormInput readonly :modelValue="selectedTheme.author"> <span>{{ $ts.author }}</span> </FormInput> - <FormTextarea readonly :value="selectedTheme.desc" v-if="selectedTheme.desc"> + <FormTextarea readonly :modelValue="selectedTheme.desc" v-if="selectedTheme.desc"> <span>{{ $ts._theme.description }}</span> </FormTextarea> - <FormTextarea readonly tall :value="selectedThemeCode"> + <FormTextarea readonly tall :modelValue="selectedThemeCode"> <span>{{ $ts._theme.code }}</span> <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $ts.copy }}</button></template> </FormTextarea> @@ -28,12 +28,12 @@ <script lang="ts"> import { defineComponent } from 'vue'; import * as JSON5 from 'json5'; -import FormTextarea from '@client/components/form/textarea.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormRadios from '@client/components/form/radios.vue'; +import FormTextarea from '@client/components/debobigego/textarea.vue'; +import FormSelect from '@client/components/debobigego/select.vue'; +import FormRadios from '@client/components/debobigego/radios.vue'; import FormBase from '@client/components/debobigego/base.vue'; import FormGroup from '@client/components/debobigego/group.vue'; -import FormInput from '@client/components/form/input.vue'; +import FormInput from '@client/components/debobigego/input.vue'; import FormButton from '@client/components/debobigego/button.vue'; import { Theme, builtinThemes } from '@client/scripts/theme'; import copyToClipboard from '@client/scripts/copy-to-clipboard'; diff --git a/src/client/pages/share.vue b/src/client/pages/share.vue index 67e598fa8f..70a9661dd0 100644 --- a/src/client/pages/share.vue +++ b/src/client/pages/share.vue @@ -1,22 +1,38 @@ <template> <div class=""> <section class="_section"> - <div class="_title" v-if="title">{{ title }}</div> <div class="_content"> - <XPostForm v-if="!posted" fixed :instant="true" :initial-text="initialText" @posted="posted = true" class="_panel"/> - <MkButton v-else primary @click="close()">{{ $ts.close }}</MkButton> + <XPostForm + v-if="state === 'writing'" + fixed + :share="true" + :initial-text="initialText" + :initial-visibility="visibility" + :initial-files="files" + :initial-local-only="localOnly" + :reply="reply" + :renote="renote" + :visible-users="visibleUsers" + @posted="state = 'posted'" + class="_panel" + /> + <MkButton v-else-if="state === 'posted'" primary @click="close()" class="close">{{ $ts.close }}</MkButton> </div> - <div class="_footer" v-if="url">{{ url }}</div> </section> </div> </template> <script lang="ts"> +// SPECIFICATION: /src/docs/ja-JP/advanced/share-page.md + import { defineComponent } from 'vue'; import MkButton from '@client/components/ui/button.vue'; import XPostForm from '@client/components/post-form.vue'; import * as os from '@client/os'; +import { noteVisibilities } from '@/types'; +import { parseAcct } from '@/misc/acct'; import * as symbols from '@client/symbols'; +import * as Misskey from 'misskey-js'; export default defineComponent({ components: { @@ -30,35 +46,139 @@ export default defineComponent({ title: this.$ts.share, icon: 'fas fa-share-alt' }, - title: null, - text: null, - url: null, - initialText: null, - posted: false, + state: 'fetching' as 'fetching' | 'writing' | 'posted', + title: null as string | null, + initialText: null as string | null, + reply: null as Misskey.entities.Note | null, + renote: null as Misskey.entities.Note | null, + visibility: null as string | null, + localOnly: null as boolean | null, + files: [] as Misskey.entities.DriveFile[], + visibleUsers: [] as Misskey.entities.User[], } }, - created() { + async created() { const urlParams = new URLSearchParams(window.location.search); + this.title = urlParams.get('title'); - this.text = urlParams.get('text'); - this.url = urlParams.get('url'); - - let text = ''; - if (this.title) text += `【${this.title}】\n`; - if (this.text) text += `${this.text}\n`; - if (this.url) text += `${this.url}`; - this.initialText = text.trim(); + const text = urlParams.get('text'); + const url = urlParams.get('url'); + + let noteText = ''; + if (this.title) noteText += `[ ${this.title} ]\n`; + // Googleニュース対策 + if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, ''); + else if (text && this.title !== text) noteText += `${text}\n`; + if (url) noteText += `${url}`; + this.initialText = noteText.trim(); + + const visibility = urlParams.get('visibility'); + if (noteVisibilities.includes(visibility)) { + this.visibility = visibility; + } + + if (this.visibility === 'specified') { + const visibleUserIds = urlParams.get('visibleUserIds'); + const visibleAccts = urlParams.get('visibleAccts'); + await Promise.all( + [ + ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), + ...(visibleAccts ? visibleAccts.split(',').map(parseAcct) : []) + ] + // TypeScriptの指示通りに変換する + .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) + .map(q => os.api('users/show', q) + .then(user => { + this.visibleUsers.push(user); + }, () => { + console.error(`Invalid user query: ${JSON.stringify(q)}`); + }) + ) + ); + } + + const localOnly = urlParams.get('localOnly'); + if (localOnly === '0') this.localOnly = false; + else if (localOnly === '1') this.localOnly = true; + + try { + //#region Reply + const replyId = urlParams.get('replyId'); + const replyUri = urlParams.get('replyUri'); + if (replyId) { + this.reply = await os.api('notes/show', { + noteId: replyId + }); + } else if (replyUri) { + const obj = await os.api('ap/show', { + uri: replyUri + }); + if (obj.type === 'Note') { + this.reply = obj.object; + } + } + //#endregion + + //#region Renote + const renoteId = urlParams.get('renoteId'); + const renoteUri = urlParams.get('renoteUri'); + if (renoteId) { + this.renote = await os.api('notes/show', { + noteId: renoteId + }); + } else if (renoteUri) { + const obj = await os.api('ap/show', { + uri: renoteUri + }); + if (obj.type === 'Note') { + this.renote = obj.object; + } + } + //#endregion + + //#region Drive files + const fileIds = urlParams.get('fileIds'); + if (fileIds) { + await Promise.all( + fileIds.split(',') + .map(fileId => os.api('drive/files/show', { fileId }) + .then(file => { + this.files.push(file); + }, () => { + console.error(`Failed to fetch a file ${fileId}`); + }) + ) + ); + } + //#endregion + } catch (e) { + os.dialog({ + type: 'error', + title: e.message, + text: e.name + }); + } + + this.state = 'writing'; }, methods: { close() { - window.close() + window.close(); + + // 閉じなければ100ms後タイムラインに + setTimeout(() => { + this.$router.push('/'); + }, 100); } } }); </script> <style lang="scss" scoped> +.close { + margin: 16px auto; +} </style> diff --git a/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue index c3444f26f6..9057d90396 100644 --- a/src/client/pages/user/index.timeline.vue +++ b/src/client/pages/user/index.timeline.vue @@ -65,4 +65,11 @@ export default defineComponent({ background: var(--bg); } } + +._fitSide_ .yrzkoczt { + > .tab { + padding-left: var(--margin); + padding-right: var(--margin); + } +} </style> diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index 0ddf73d572..f74bf49883 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -181,6 +181,7 @@ </template> <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/> <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/> + <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/> <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/> @@ -223,6 +224,7 @@ export default defineComponent({ MkTab, MkInfo, XFollowList: defineAsyncComponent(() => import('./follow-list.vue')), + XReactions: defineAsyncComponent(() => import('./reactions.vue')), XClips: defineAsyncComponent(() => import('./clips.vue')), XPages: defineAsyncComponent(() => import('./pages.vue')), XGallery: defineAsyncComponent(() => import('./gallery.vue')), @@ -268,7 +270,12 @@ export default defineComponent({ title: this.$ts.overview, icon: 'fas fa-home', onClick: () => { this.$router.push('/@' + getAcct(this.user)); }, - }, { + }, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{ + active: this.page === 'reactions', + title: this.$ts.reaction, + icon: 'fas fa-laugh', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/reactions'); }, + }] : [], { active: this.page === 'clips', title: this.$ts.clips, icon: 'fas fa-paperclip', diff --git a/src/client/pages/user/reactions.vue b/src/client/pages/user/reactions.vue new file mode 100644 index 0000000000..5ac7e01027 --- /dev/null +++ b/src/client/pages/user/reactions.vue @@ -0,0 +1,81 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb"> + <div class="header"> + <MkAvatar class="avatar" :user="user"/> + <MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> + <MkTime :time="item.createdAt" class="createdAt"/> + </div> + <MkNote :note="item.note" @update:note="updated(note, $event)" :key="item.id"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@client/components/ui/pagination.vue'; +import MkNote from '@client/components/note.vue'; +import MkReactionIcon from '@client/components/reaction-icon.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkNote, + MkReactionIcon, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/reactions', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + }, +}); +</script> + +<style lang="scss" scoped> +.afdcfbfb { + > .header { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 8px; + border-bottom: solid 2px var(--divider); + + > .avatar { + width: 24px; + height: 24px; + margin-right: 8px; + } + + > .reaction { + width: 32px; + height: 32px; + } + + > .createdAt { + margin-left: auto; + } + } +} +</style> diff --git a/src/client/router.ts b/src/client/router.ts index 56dc948669..f588bb04fc 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -70,8 +70,8 @@ const defaultRoutes = [ { path: '/my/antennas/:antennaId', component: page('my-antennas/edit'), props: true }, { path: '/my/clips', component: page('my-clips/index') }, { path: '/scratchpad', component: page('scratchpad') }, - { path: '/instance/:page(.*)?', component: page('instance/index'), props: route => ({ initialPage: route.params.page || null }) }, - { path: '/instance', component: page('instance/index') }, + { path: '/admin/:page(.*)?', component: page('admin/index'), props: route => ({ initialPage: route.params.page || null }) }, + { path: '/admin', component: page('admin/index') }, { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) }, diff --git a/src/client/scripts/hpml/lib.ts b/src/client/scripts/hpml/lib.ts index 150a04732f..2a1ac73a40 100644 --- a/src/client/scripts/hpml/lib.ts +++ b/src/client/scripts/hpml/lib.ts @@ -1,11 +1,11 @@ import * as tinycolor from 'tinycolor2'; -import Chart from 'chart.js'; import { Hpml } from './evaluator'; import { values, utils } from '@syuilo/aiscript'; import { Fn, HpmlScope } from '.'; import { Expr } from './expr'; import * as seedrandom from 'seedrandom'; +/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color // https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs Chart.pluginService.register({ beforeDraw: (chart, easing) => { @@ -18,6 +18,7 @@ Chart.pluginService.register({ } } }); +*/ export function initAiLib(hpml: Hpml) { return { @@ -49,11 +50,12 @@ export function initAiLib(hpml: Hpml) { ])); }), 'MkPages:chart': values.FN_NATIVE(([id, opts]) => { + /* TODO utils.assertString(id); utils.assertObject(opts); const canvas = hpml.canvases[id.value]; const color = getComputedStyle(document.documentElement).getPropertyValue('--accent'); - Chart.defaults.global.defaultFontColor = '#555'; + Chart.defaults.color = '#555'; const chart = new Chart(canvas, { type: opts.value.get('type').value, data: { @@ -122,6 +124,7 @@ export function initAiLib(hpml: Hpml) { }) } }); + */ }) }; } diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts index e79d54fa6d..8b63821293 100644 --- a/src/client/scripts/theme.ts +++ b/src/client/scripts/theme.ts @@ -20,12 +20,14 @@ export const builtinThemes = [ require('@client/themes/l-apricot.json5'), require('@client/themes/l-rainy.json5'), require('@client/themes/l-vivid.json5'), + require('@client/themes/l-sushi.json5'), require('@client/themes/d-dark.json5'), require('@client/themes/d-persimmon.json5'), require('@client/themes/d-astro.json5'), require('@client/themes/d-future.json5'), require('@client/themes/d-botanical.json5'), + require('@client/themes/d-pumpkin.json5'), require('@client/themes/d-black.json5'), ] as Theme[]; diff --git a/src/client/themes/d-astro.json5 b/src/client/themes/d-astro.json5 index 2350e3d46d..c6a927ec3a 100644 --- a/src/client/themes/d-astro.json5 +++ b/src/client/themes/d-astro.json5 @@ -1,7 +1,7 @@ { id: '080a01c5-377d-4fbb-88cc-6bb5d04977ea', base: 'dark', - name: 'Mi Astro', + name: 'Mi Astro Dark', author: 'syuilo', props: { bg: '#232125', diff --git a/src/client/themes/d-future.json5 b/src/client/themes/d-future.json5 index 1882609121..b6fa1ab0c1 100644 --- a/src/client/themes/d-future.json5 +++ b/src/client/themes/d-future.json5 @@ -1,7 +1,7 @@ { id: '32a637ef-b47a-4775-bb7b-bacbb823f865', - name: 'Mi Future', + name: 'Mi Future Dark', author: 'syuilo', base: 'dark', diff --git a/src/client/themes/d-persimmon.json5 b/src/client/themes/d-persimmon.json5 index 11e9994f5e..e36265ff10 100644 --- a/src/client/themes/d-persimmon.json5 +++ b/src/client/themes/d-persimmon.json5 @@ -1,7 +1,7 @@ { id: 'c503d768-7c70-4db2-a4e6-08264304bc8d', - name: 'Mi Persimmon', + name: 'Mi Persimmon Dark', author: 'syuilo', base: 'dark', diff --git a/src/client/themes/d-pumpkin.json5 b/src/client/themes/d-pumpkin.json5 new file mode 100644 index 0000000000..064ca4577b --- /dev/null +++ b/src/client/themes/d-pumpkin.json5 @@ -0,0 +1,88 @@ +{ + id: '0b64fef3-02c7-20b5-dd87-b3f77e2b4301', + + name: 'Mi Pumpkin Dark', + author: 'syuilo', + + base: 'dark', + + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: 'rgb(37, 32, 47)', + fg: '#e0d5c0', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + cwBg: '#687390', + cwFg: '#393f4f', + link: 'rgb(172, 193, 68)', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: 'rgb(242, 133, 36)', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: 'rgb(110, 179, 72)', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(255, 255, 255, 0.1)', + hashtag: 'rgb(188, 90, 255)', + mention: 'rgb(72, 179, 139)', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: 'rgba(255, 255, 255, 0.05)', + switchBg: 'rgba(255, 255, 255, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '@accent', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + fgOnAccent: '#000', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + }, +} diff --git a/src/client/themes/l-apricot.json5 b/src/client/themes/l-apricot.json5 index 74cb24d407..1ed5525575 100644 --- a/src/client/themes/l-apricot.json5 +++ b/src/client/themes/l-apricot.json5 @@ -1,7 +1,7 @@ { id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b', - name: 'Mi Apricot', + name: 'Mi Apricot Light', author: 'syuilo', base: 'light', diff --git a/src/client/themes/l-rainy.json5 b/src/client/themes/l-rainy.json5 index 1edde1cabf..283dd74c6c 100644 --- a/src/client/themes/l-rainy.json5 +++ b/src/client/themes/l-rainy.json5 @@ -1,7 +1,7 @@ { id: 'a58a0abb-ff8c-476a-8dec-0ad7837e7e96', - name: 'Mi Rainy', + name: 'Mi Rainy Light', author: 'syuilo', base: 'light', diff --git a/src/client/themes/l-sushi.json5 b/src/client/themes/l-sushi.json5 new file mode 100644 index 0000000000..5846927d65 --- /dev/null +++ b/src/client/themes/l-sushi.json5 @@ -0,0 +1,18 @@ +{ + id: '213273e5-7d20-d5f0-6e36-1b6a4f67115c', + + name: 'Mi Sushi Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#e36749', + bg: '#f0eee9', + fg: '#5f5f5f', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '#229e82', + }, +} diff --git a/src/client/themes/l-vivid.json5 b/src/client/themes/l-vivid.json5 index 0f4abe0a45..b3c08f38ae 100644 --- a/src/client/themes/l-vivid.json5 +++ b/src/client/themes/l-vivid.json5 @@ -1,7 +1,7 @@ { id: '6128c2a9-5c54-43fe-a47d-17942356470b', - name: 'Mi Vivid', + name: 'Mi Vivid Light', author: 'syuilo', base: 'light', diff --git a/src/client/ui/_common_/sidebar.vue b/src/client/ui/_common_/sidebar.vue index d00327b096..ec2a17ab86 100644 --- a/src/client/ui/_common_/sidebar.vue +++ b/src/client/ui/_common_/sidebar.vue @@ -25,7 +25,7 @@ </component> </template> <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/instance" v-click-anime> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" v-click-anime> <i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span> </MkA> <button class="item _button" @click="more" v-click-anime> diff --git a/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue index 0cacaf77e7..64b8d08cbc 100644 --- a/src/client/ui/chat/post-form.vue +++ b/src/client/ui/chat/post-form.vue @@ -100,7 +100,7 @@ export default defineComponent({ type: Object, required: false }, - instant: { + share: { type: Boolean, required: false, default: false @@ -277,7 +277,7 @@ export default defineComponent({ this.$nextTick(() => { // 書きかけの投稿を復元 - if (!this.instant && !this.mention && !this.specified) { + if (!this.share && !this.mention && !this.specified) { const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; if (draft) { this.text = draft.data.text; @@ -507,8 +507,6 @@ export default defineComponent({ }, saveDraft() { - if (this.instant) return; - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); data[this.draftKey] = { diff --git a/src/client/ui/default.header.vue b/src/client/ui/default.header.vue index 4f6363e82d..908a4719a4 100644 --- a/src/client/ui/default.header.vue +++ b/src/client/ui/default.header.vue @@ -13,7 +13,7 @@ </component> </template> <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/instance" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.instance"> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.instance"> <i class="fas fa-server fa-fw"></i> </MkA> <button class="item _button" @click="more" v-click-anime> diff --git a/src/client/ui/default.sidebar.vue b/src/client/ui/default.sidebar.vue index e36febb7fa..cce74a53cd 100644 --- a/src/client/ui/default.sidebar.vue +++ b/src/client/ui/default.sidebar.vue @@ -20,7 +20,7 @@ </component> </template> <div class="divider"></div> - <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/instance" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime> <i class="fas fa-server fa-fw"></i><span class="text">{{ $ts.instance }}</span> </MkA> <button class="item _button" @click="more" v-click-anime> |