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/components | |
| 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/components')
| -rw-r--r-- | src/client/components/chart.vue | 628 | ||||
| -rw-r--r-- | src/client/components/form/input.vue | 2 | ||||
| -rw-r--r-- | src/client/components/form/radios.vue | 12 | ||||
| -rw-r--r-- | src/client/components/form/select.vue | 65 | ||||
| -rw-r--r-- | src/client/components/form/textarea.vue | 2 | ||||
| -rw-r--r-- | src/client/components/global/emoji.vue | 8 | ||||
| -rw-r--r-- | src/client/components/global/header.vue | 6 | ||||
| -rw-r--r-- | src/client/components/instance-stats.vue | 487 | ||||
| -rw-r--r-- | src/client/components/mfm.ts | 2 | ||||
| -rw-r--r-- | src/client/components/number-diff.vue | 47 | ||||
| -rw-r--r-- | src/client/components/post-form.vue | 38 | ||||
| -rw-r--r-- | src/client/components/queue-chart.vue | 212 | ||||
| -rw-r--r-- | src/client/components/tab.vue | 2 | ||||
| -rw-r--r-- | src/client/components/ui/container.vue | 9 | ||||
| -rw-r--r-- | src/client/components/ui/menu.vue | 5 | ||||
| -rw-r--r-- | src/client/components/ui/popup-menu.vue | 6 | ||||
| -rw-r--r-- | src/client/components/ui/super-menu.vue | 2 |
17 files changed, 1021 insertions, 512 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; |