diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
| commit | 84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b (patch) | |
| tree | a182502a5192992d873e7a7fcbf01662bb0dfca2 /packages/client/src/components | |
| parent | Merge pull request #8821 from misskey-dev/develop (diff) | |
| parent | 12.112.1 (diff) | |
| download | misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.gz misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.bz2 misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/components')
86 files changed, 2830 insertions, 2559 deletions
diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue index 5114349620..6b8e36c4da 100644 --- a/packages/client/src/components/abuse-report-window.vue +++ b/packages/client/src/components/abuse-report-window.vue @@ -1,5 +1,5 @@ <template> -<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> +<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> <template #header> <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i> <I18n :src="i18n.ts.reportAbuseOf" tag="span"> @@ -40,7 +40,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const window = ref<InstanceType<typeof XWindow>>(); +const uiWindow = ref<InstanceType<typeof XWindow>>(); const comment = ref(props.initialComment || ''); function send() { @@ -52,7 +52,7 @@ function send() { type: 'success', text: i18n.ts.abuseReported }); - window.value?.close(); + uiWindow.value?.close(); emit('closed'); }); } diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/abuse-report.vue index a947406f88..2b89eef85a 100644 --- a/packages/client/src/components/abuse-report.vue +++ b/packages/client/src/components/abuse-report.vue @@ -1,13 +1,19 @@ <template> -<div class="bcekxzvu _card _gap"> - <div class="_content target"> - <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> - <MkA v-user-preview="report.targetUserId" class="info" :to="userPage(report.targetUser)"> - <MkUserName class="name" :user="report.targetUser"/> - <MkAcct class="acct" :user="report.targetUser" style="display: block;"/> +<div class="bcekxzvu _gap _panel"> + <div class="target"> + <MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`"> + <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/> + <div class="names"> + <MkUserName class="name" :user="report.targetUser"/> + <MkAcct class="acct" :user="report.targetUser" style="display: block;"/> + </div> </MkA> + <MkKeyValue class="_formBlock"> + <template #key>{{ $ts.registeredDate }}</template> + <template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template> + </MkKeyValue> </div> - <div class="_content"> + <div class="detail"> <div> <Mfm :text="report.comment"/> </div> @@ -18,85 +24,85 @@ <MkAcct :user="report.assignee"/> </div> <div><MkTime :time="report.createdAt"/></div> - </div> - <div class="_footer"> - <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> - {{ $ts.forwardReport }} - <template #caption>{{ $ts.forwardReportIsAnonymous }}</template> - </MkSwitch> - <MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton> + <div class="action"> + <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> + {{ $ts.forwardReport }} + <template #caption>{{ $ts.forwardReportIsAnonymous }}</template> + </MkSwitch> + <MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton> + </div> </div> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; - +<script lang="ts" setup> import MkButton from '@/components/ui/button.vue'; import MkSwitch from '@/components/form/switch.vue'; +import MkKeyValue from '@/components/key-value.vue'; import { acct, userPage } from '@/filters/user'; import * as os from '@/os'; -export default defineComponent({ - components: { - MkButton, - MkSwitch, - }, - - props: { - report: { - type: Object, - required: true, - } - }, +const props = defineProps<{ + report: any; +}>(); - emits: ['resolved'], +const emit = defineEmits<{ + (ev: 'resolved', reportId: string): void; +}>(); - data() { - return { - forward: this.report.forwarded, - }; - }, +let forward = $ref(props.report.forwarded); - methods: { - acct, - userPage, - - resolve() { - os.apiWithDialog('admin/resolve-abuse-user-report', { - forward: this.forward, - reportId: this.report.id, - }).then(() => { - this.$emit('resolved', this.report.id); - }); - } - } -}); +function resolve() { + os.apiWithDialog('admin/resolve-abuse-user-report', { + forward: forward, + reportId: props.report.id, + }).then(() => { + emit('resolved', props.report.id); + }); +} </script> <style lang="scss" scoped> .bcekxzvu { + display: flex; + > .target { - display: flex; - width: 100%; + width: 35%; box-sizing: border-box; text-align: left; - align-items: center; - - > .avatar { - width: 42px; - height: 42px; - } + padding: 24px; + border-right: solid 1px var(--divider); > .info { - margin-left: 0.3em; - padding: 0 8px; - flex: 1; + display: flex; + box-sizing: border-box; + align-items: center; + padding: 14px; + border-radius: 8px; + --c: rgb(255 196 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + + > .avatar { + width: 42px; + height: 42px; + } + + > .names { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; - > .name { - font-weight: bold; + > .name { + font-weight: bold; + } } } } + + > .detail { + flex: 1; + padding: 24px; + } } </style> diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue index 1e4a4506f7..144281e3c3 100644 --- a/packages/client/src/components/autocomplete.vue +++ b/packages/client/src/components/autocomplete.vue @@ -20,6 +20,7 @@ <span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> <span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span> <span v-else class="emoji">{{ emoji.emoji }}</span> + <!-- eslint-disable-next-line vue/no-v-html --> <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> <span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span> </li> @@ -35,6 +36,7 @@ <script lang="ts"> import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import contains from '@/scripts/contains'; +import { char2filePath } from '@/scripts/twemoji-base'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { acct } from '@/filters/user'; import * as os from '@/os'; @@ -42,7 +44,6 @@ import { MFM_TAGS } from '@/scripts/mfm-tags'; import { defaultStore } from '@/store'; import { emojilist } from '@/scripts/emojilist'; import { instance } from '@/instance'; -import { twemojiSvgBase } from '@/scripts/twemoji-base'; import { i18n } from '@/i18n'; type EmojiDef = { @@ -55,16 +56,10 @@ type EmojiDef = { const lib = emojilist.filter(x => x.category !== 'flags'); -const char2file = (char: string) => { - let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); - if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); - return codes.filter(x => x && x.length).join('-'); -}; - const emjdb: EmojiDef[] = lib.map(x => ({ emoji: x.char, name: x.name, - url: `${twemojiSvgBase}/${char2file(x.char)}.svg` + url: char2filePath(x.char), })); for (const x of lib) { @@ -74,7 +69,7 @@ for (const x of lib) { emoji: x.char, name: k, aliasOf: x.name, - url: `${twemojiSvgBase}/${char2file(x.char)}.svg` + url: char2filePath(x.char), }); } } diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue index 183658471b..7360734914 100644 --- a/packages/client/src/components/captcha.vue +++ b/packages/client/src/components/captcha.vue @@ -51,7 +51,7 @@ const variable = computed(() => { } }); -const loaded = computed(() => !!window[variable.value]); +const loaded = !!window[variable.value]; const src = computed(() => { switch (props.provider) { @@ -62,7 +62,7 @@ const src = computed(() => { const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); -if (loaded.value) { +if (loaded) { available.value = true; } else { (document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { @@ -74,7 +74,7 @@ if (loaded.value) { } function reset() { - if (captcha.value?.reset) captcha.value.reset(); + if (captcha.value.reset) captcha.value.reset(); } function requestRender() { diff --git a/packages/client/src/components/chart-tooltip.vue b/packages/client/src/components/chart-tooltip.vue index 20e094a5a7..9b57a1b3d5 100644 --- a/packages/client/src/components/chart-tooltip.vue +++ b/packages/client/src/components/chart-tooltip.vue @@ -1,25 +1,27 @@ <template> -<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'left'" :inner-margin="16" @closed="emit('closed')"> - <div v-if="title" class="qpcyisrl"> - <div class="title">{{ title }}</div> - <div v-for="x in series" class="series"> - <span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> - <span>{{ x.text }}</span> - </div> +<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')"> + <div v-if="title || series" class="qpcyisrl"> + <div v-if="title" class="title">{{ title }}</div> + <template v-if="series"> + <div v-for="x in series" class="series"> + <span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> + <span>{{ x.text }}</span> + </div> + </template> </div> </MkTooltip> </template> <script lang="ts" setup> -import { } from 'vue'; +import { } from 'vue'; import MkTooltip from './ui/tooltip.vue'; const props = defineProps<{ showing: boolean; x: number; y: number; - title: string; - series: { + title?: string; + series?: { backgroundColor: string; borderColor: string; text: string; diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue index 4e9c4e587a..fc7c4ff950 100644 --- a/packages/client/src/components/chart.vue +++ b/packages/client/src/components/chart.vue @@ -13,7 +13,7 @@ id-denylist violation when setting it. This is causing about 60+ lint issues. As this is part of Chart.js's API it makes sense to disable the check here. */ -import { defineProps, onMounted, ref, watch, PropType, onUnmounted } from 'vue'; +import { onMounted, ref, watch, PropType, onUnmounted } from 'vue'; import { Chart, ArcElement, @@ -39,7 +39,7 @@ import zoomPlugin from 'chartjs-plugin-zoom'; //import gradient from 'chartjs-plugin-gradient'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import MkChartTooltip from '@/components/chart-tooltip.vue'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; const props = defineProps({ src: { @@ -53,7 +53,7 @@ const props = defineProps({ limit: { type: Number, required: false, - default: 90 + default: 90, }, span: { type: String as PropType<'hour' | 'day'>, @@ -62,22 +62,22 @@ const props = defineProps({ detailed: { type: Boolean, required: false, - default: false + default: false, }, stacked: { type: Boolean, required: false, - default: false + default: false, }, bar: { type: Boolean, required: false, - default: false + default: false, }, aspectRatio: { type: Number, required: false, - default: null + default: null, }, }); @@ -156,46 +156,11 @@ const getDate = (ago: number) => { const format = (arr) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), - y: v + y: v, })); }; -const tooltipShowing = ref(false); -const tooltipX = ref(0); -const tooltipY = ref(0); -const tooltipTitle = ref(null); -const tooltipSeries = ref(null); -let disposeTooltipComponent; - -os.popup(MkChartTooltip, { - showing: tooltipShowing, - x: tooltipX, - y: tooltipY, - title: tooltipTitle, - series: tooltipSeries, -}, {}).then(({ dispose }) => { - disposeTooltipComponent = dispose; -}); - -function externalTooltipHandler(context) { - if (context.tooltip.opacity === 0) { - tooltipShowing.value = false; - return; - } - - tooltipTitle.value = context.tooltip.title[0]; - tooltipSeries.value = context.tooltip.body.map((b, i) => ({ - backgroundColor: context.tooltip.labelColors[i].backgroundColor, - borderColor: context.tooltip.labelColors[i].borderColor, - text: b.lines[0], - })); - - const rect = context.chart.canvas.getBoundingClientRect(); - - tooltipShowing.value = true; - tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; - tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; -} +const { handler: externalTooltipHandler } = useChartTooltip(); const render = () => { if (chartInstance) { @@ -343,7 +308,7 @@ const render = () => { min: 'original', max: 'original', }, - } + }, } : undefined, //gradient, }, @@ -367,8 +332,8 @@ const render = () => { ctx.stroke(); ctx.restore(); } - } - }] + }, + }], }); }; @@ -377,7 +342,7 @@ const exportData = () => { }; const fetchFederationChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span }); return { series: [{ name: 'Received', @@ -427,36 +392,36 @@ const fetchFederationChart = async (): Promise<typeof chartData> => { }; const fetchApRequestChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/ap-request', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span }); return { series: [{ name: 'In', type: 'area', color: '#008FFB', - data: format(raw.inboxReceived) + data: format(raw.inboxReceived), }, { name: 'Out (succ)', type: 'area', color: '#00E396', - data: format(raw.deliverSucceeded) + data: format(raw.deliverSucceeded), }, { name: 'Out (fail)', type: 'area', color: '#FEB019', - data: format(raw.deliverFailed) - }] + data: format(raw.deliverFailed), + }], }; }; const fetchNotesChart = async (type: string): Promise<typeof chartData> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', type: 'line', 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)) + : sum(raw[type].inc, negate(raw[type].dec)), ), color: '#888888', }, { @@ -464,7 +429,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) - : raw[type].diffs.renote + : raw[type].diffs.renote, ), color: colors.green, }, { @@ -472,7 +437,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) - : raw[type].diffs.reply + : raw[type].diffs.reply, ), color: colors.yellow, }, { @@ -480,7 +445,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) - : raw[type].diffs.normal + : raw[type].diffs.normal, ), color: colors.blue, }, { @@ -488,7 +453,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile) - : raw[type].diffs.withFile + : raw[type].diffs.withFile, ), color: colors.purple, }], @@ -496,7 +461,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { }; const fetchNotesTotalChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -515,35 +480,35 @@ const fetchNotesTotalChart = async (): Promise<typeof chartData> => { }; const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('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)) + : 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)) + : 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)) + : sum(raw.remote.inc, negate(raw.remote.dec)), ), }], }; }; const fetchActiveUsersChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Read & Write', @@ -595,7 +560,7 @@ const fetchActiveUsersChart = async (): Promise<typeof chartData> => { }; const fetchDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -607,8 +572,8 @@ const fetchDriveChart = async (): Promise<typeof chartData> => { raw.local.incSize, negate(raw.local.decSize), raw.remote.incSize, - negate(raw.remote.decSize) - ) + negate(raw.remote.decSize), + ), ), }, { name: 'Local +', @@ -631,7 +596,7 @@ const fetchDriveChart = async (): Promise<typeof chartData> => { }; const fetchDriveFilesChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -642,8 +607,8 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { raw.local.incCount, negate(raw.local.decCount), raw.remote.incCount, - negate(raw.remote.decCount) - ) + negate(raw.remote.decCount), + ), ), }, { name: 'Local +', @@ -666,29 +631,29 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('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) + data: format(raw.requests.received), }, { name: 'Out (succ)', type: 'area', color: '#00E396', - data: format(raw.requests.succeeded) + data: format(raw.requests.succeeded), }, { name: 'Out (fail)', type: 'area', color: '#FEB019', - data: format(raw.requests.failed) - }] + data: format(raw.requests.failed), + }], }; }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -696,14 +661,14 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData color: '#008FFB', data: format(total ? raw.users.total - : sum(raw.users.inc, negate(raw.users.dec)) - ) - }] + : sum(raw.users.inc, negate(raw.users.dec)), + ), + }], }; }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -711,14 +676,14 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData color: '#008FFB', data: format(total ? raw.notes.total - : sum(raw.notes.inc, negate(raw.notes.dec)) - ) - }] + : sum(raw.notes.inc, negate(raw.notes.dec)), + ), + }], }; }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -726,22 +691,22 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = color: '#008FFB', data: format(total ? raw.following.total - : sum(raw.following.inc, negate(raw.following.dec)) - ) + : 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)) - ) - }] + : sum(raw.followers.inc, negate(raw.followers.dec)), + ), + }], }; }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -750,14 +715,14 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char color: '#008FFB', data: format(total ? raw.drive.totalUsage - : sum(raw.drive.incUsage, negate(raw.drive.decUsage)) - ) - }] + : sum(raw.drive.incUsage, negate(raw.drive.decUsage)), + ), + }], }; }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -765,14 +730,14 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char color: '#008FFB', data: format(total ? raw.drive.totalFiles - : sum(raw.drive.incFiles, negate(raw.drive.decFiles)) - ) - }] + : sum(raw.drive.incFiles, negate(raw.drive.decFiles)), + ), + }], }; }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [...(props.args.withoutAll ? [] : [{ name: 'All', @@ -804,7 +769,7 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -819,7 +784,7 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -834,7 +799,7 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Inc', @@ -891,10 +856,6 @@ watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); - -onUnmounted(() => { - if (disposeTooltipComponent) disposeTooltipComponent(); -}); /* eslint-enable id-denylist */ </script> diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue index 45a38afe04..b074028821 100644 --- a/packages/client/src/components/code-core.vue +++ b/packages/client/src/components/code-core.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/no-v-html --> <template> <code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code> <pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre> @@ -5,7 +6,7 @@ <script lang="ts" setup> import { computed } from 'vue'; -import 'prismjs'; +import Prism from 'prismjs'; import 'prismjs/themes/prism-okaidia.css'; const props = defineProps<{ diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue index dd24440e82..16c77f7267 100644 --- a/packages/client/src/components/drive-file-thumbnail.vue +++ b/packages/client/src/components/drive-file-thumbnail.vue @@ -1,6 +1,6 @@ <template> <div ref="thumbnail" class="zdjebgpv"> - <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/> + <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/> <i v-else-if="is === 'image'" class="fas fa-file-image icon"></i> <i v-else-if="is === 'video'" class="fas fa-file-video icon"></i> <i v-else-if="is === 'audio' || is === 'midi'" class="fas fa-music icon"></i> @@ -33,16 +33,16 @@ const is = computed(() => { if (props.file.type.endsWith('/pdf')) return 'pdf'; if (props.file.type.startsWith('text/')) return 'textfile'; if ([ - "application/zip", - "application/x-cpio", - "application/x-bzip", - "application/x-bzip2", - "application/java-archive", - "application/x-rar-compressed", - "application/x-tar", - "application/gzip", - "application/x-7z-compressed" - ].some(archiveType => archiveType === props.file.type)) return 'archive'; + 'application/zip', + 'application/x-cpio', + 'application/x-bzip', + 'application/x-bzip2', + 'application/java-archive', + 'application/x-rar-compressed', + 'application/x-tar', + 'application/gzip', + 'application/x-7z-compressed', + ].some(archiveType => archiveType === props.file.type)) return 'archive'; return 'unknown'; }); @@ -57,9 +57,9 @@ const isThumbnailAvailable = computed(() => { .zdjebgpv { position: relative; display: flex; - background: #e1e1e1; + background: var(--panel); border-radius: 8px; - overflow: clip; + overflow: hidden; overflow: clip; > .icon-sub { position: absolute; diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/emoji-picker.section.vue index 52f7047487..e2a80d5466 100644 --- a/packages/client/src/components/emoji-picker.section.vue +++ b/packages/client/src/components/emoji-picker.section.vue @@ -1,15 +1,17 @@ <template> +<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと --> <section> <header class="_acrylic" @click="shown = !shown"> <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }}) </header> - <div v-if="shown"> - <button v-for="emoji in emojis" + <div v-if="shown" class="body"> + <button + v-for="emoji in emojis" :key="emoji" - class="_button" + class="_button item" @click="emit('chosen', emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue index 64732e7033..4a46e0ecfb 100644 --- a/packages/client/src/components/emoji-picker.vue +++ b/packages/client/src/components/emoji-picker.vue @@ -3,63 +3,67 @@ <input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @paste.stop="paste" @keyup.enter="done()"> <div ref="emojis" class="emojis"> <section class="result"> - <div v-if="searchResultCustom.length > 0"> - <button v-for="emoji in searchResultCustom" + <div v-if="searchResultCustom.length > 0" class="body"> + <button + v-for="emoji in searchResultCustom" :key="emoji.id" - class="_button" + class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > <!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>--> - <img :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + <img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> </button> </div> - <div v-if="searchResultUnicode.length > 0"> - <button v-for="emoji in searchResultUnicode" + <div v-if="searchResultUnicode.length > 0" class="body"> + <button + v-for="emoji in searchResultUnicode" :key="emoji.name" - class="_button" + class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji.char"/> + <MkEmoji class="emoji" :emoji="emoji.char"/> </button> </div> </section> - <div v-if="tab === 'index'" class="index"> + <div v-if="tab === 'index'" class="group index"> <section v-if="showPinned"> - <div> - <button v-for="emoji in pinned" + <div class="body"> + <button + v-for="emoji in pinned" :key="emoji" - class="_button" + class="_button item" tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> <section> <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ i18n.ts.recentUsed }}</header> - <div> - <button v-for="emoji in recentlyUsedEmojis" + <div class="body"> + <button + v-for="emoji in recentlyUsedEmojis" :key="emoji" - class="_button" + class="_button item" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> </div> - <div> + <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header> <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.ts.other }}</XSection> </div> - <div> + <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.emoji }}</header> <XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection> </div> @@ -76,6 +80,7 @@ <script lang="ts" setup> import { ref, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; +import XSection from './emoji-picker.section.vue'; import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import Ripple from '@/components/ripple.vue'; @@ -83,7 +88,6 @@ import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; import { deviceKind } from '@/scripts/device-kind'; import { emojiCategories, instance } from '@/instance'; -import XSection from './emoji-picker.section.vue'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; @@ -266,7 +270,7 @@ watch(q, () => { function focus() { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { search.value?.focus({ - preventScroll: true + preventScroll: true, }); } } @@ -415,19 +419,16 @@ defineExpose({ font-size: 15px; } - > div { + > .body { display: grid; grid-template-columns: var(--columns); + font-size: 30px; - > button { + > .item { aspect-ratio: 1 / 1; width: auto; height: auto; min-width: 0; - - > * { - font-size: 30px; - } } } } @@ -478,7 +479,7 @@ defineExpose({ display: none; } - > div { + > .group { &:not(.index) { padding: 4px 0 8px 0; border-top: solid 0.5px var(--divider); @@ -513,16 +514,18 @@ defineExpose({ } } - > div { + > .body { position: relative; padding: $pad; - > button { + > .item { position: relative; padding: 0; width: var(--eachSize); height: var(--eachSize); + contain: strict; border-radius: 4px; + font-size: 24px; &:focus-visible { outline: solid 2px var(--focus); @@ -538,8 +541,7 @@ defineExpose({ box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); } - > * { - font-size: 24px; + > .emoji { height: 1.25em; vertical-align: -.25em; pointer-events: none; diff --git a/packages/client/src/components/file-list-for-admin.vue b/packages/client/src/components/file-list-for-admin.vue new file mode 100644 index 0000000000..489c017a93 --- /dev/null +++ b/packages/client/src/components/file-list-for-admin.vue @@ -0,0 +1,118 @@ +<template> +<div> + <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> + <MkA + v-for="file in items" + :key="file.id" + v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" + :to="`/admin/file/${file.id}`" + class="file _button" + > + <div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div v-if="viewMode === 'list'" class="body"> + <div> + <small style="opacity: 0.7;">{{ file.name }}</small> + </div> + <div> + <MkAcct v-if="file.user" :user="file.user"/> + <div v-else>{{ i18n.ts.system }}</div> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + </div> + </MkA> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + pagination: any; + viewMode: 'grid' | 'list'; +}>(); +</script> + +<style lang="scss" scoped> +@keyframes sensitive-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +.urempief { + margin-top: var(--margin); + + &.list { + > .file { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + &:hover { + color: var(--accent); + } + + > .thumbnail { + width: 128px; + height: 128px; + } + + > .body { + margin-left: 0.3em; + padding: 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + } + } + } + + &.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .file { + position: relative; + aspect-ratio: 1; + + > .thumbnail { + width: 100%; + height: 100%; + } + + > .sensitive-label { + position: absolute; + z-index: 10; + top: 8px; + left: 8px; + padding: 2px 4px; + background: #ff0000bf; + color: #fff; + border-radius: 4px; + font-size: 85%; + animation: sensitive-blink 1s infinite; + } + } + } +} +</style> diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/forgot-password.vue index 19c1f23c85..6ed89d45d7 100644 --- a/packages/client/src/components/forgot-password.vue +++ b/packages/client/src/components/forgot-password.vue @@ -9,12 +9,12 @@ <form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit"> <div class="main _formRoot"> - <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required> + <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> </MkInput> - <MkInput v-model="email" class="_formBlock" type="email" spellcheck="false" required> + <MkInput v-model="email" class="_formBlock" type="email" :spellcheck="false" required> <template #label>{{ i18n.ts.emailAddress }}</template> <template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template> </MkInput> diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/form-dialog.vue index 11459f5937..f05dde16f8 100644 --- a/packages/client/src/components/form-dialog.vue +++ b/packages/client/src/components/form-dialog.vue @@ -1,5 +1,6 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="450" :can-close="false" :with-ok-button="true" @@ -37,10 +38,10 @@ <option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option> </FormSelect> <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]" class="_formBlock"> - <template #caption><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> <option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option> </FormRadios> - <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock"> + <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </FormRange> @@ -55,7 +56,6 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; import FormInput from './form/input.vue'; import FormTextarea from './form/textarea.vue'; import FormSwitch from './form/switch.vue'; @@ -63,6 +63,7 @@ import FormSelect from './form/select.vue'; import FormRange from './form/range.vue'; import MkButton from './ui/button.vue'; import FormRadios from './form/radios.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; export default defineComponent({ components: { @@ -91,31 +92,31 @@ export default defineComponent({ data() { return { - values: {} + values: {}, }; }, created() { for (const item in this.form) { - this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null; + this.values[item] = this.form[item].default ?? null; } }, methods: { ok() { this.$emit('done', { - result: this.values + result: this.values, }); this.$refs.dialog.close(); }, cancel() { this.$emit('done', { - canceled: true + canceled: true, }); this.$refs.dialog.close(); - } - } + }, + }, }); </script> diff --git a/packages/client/src/components/form/checkbox.vue b/packages/client/src/components/form/checkbox.vue new file mode 100644 index 0000000000..fadb770aee --- /dev/null +++ b/packages/client/src/components/form/checkbox.vue @@ -0,0 +1,143 @@ +<template> +<div + class="ziffeoms" + :class="{ disabled, checked }" +> + <input + ref="input" + type="checkbox" + :disabled="disabled" + @keydown.enter="toggle" + > + <span ref="button" v-adaptive-border v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> + <i class="check fas fa-check"></i> + </span> + <span class="label"> + <!-- TODO: 無名slotの方は廃止 --> + <span @click="toggle"><slot name="label"></slot><slot></slot></span> + <p class="caption"><slot name="caption"></slot></p> + </span> +</div> +</template> + +<script lang="ts" setup> +import { toRefs, Ref } from 'vue'; +import * as os from '@/os'; +import Ripple from '@/components/ripple.vue'; + +const props = defineProps<{ + modelValue: boolean | Ref<boolean>; + disabled?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', v: boolean): void; +}>(); + +let button = $ref<HTMLElement>(); +const checked = toRefs(props).modelValue; +const toggle = () => { + if (props.disabled) return; + emit('update:modelValue', !checked.value); + + if (!checked.value) { + const rect = button.getBoundingClientRect(); + const x = rect.left + (button.offsetWidth / 2); + const y = rect.top + (button.offsetHeight / 2); + os.popup(Ripple, { x, y, particle: false }, {}, 'end'); + } +}; +</script> + +<style lang="scss" scoped> +.ziffeoms { + position: relative; + display: flex; + transition: all 0.2s ease; + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-flex; + flex-shrink: 0; + margin: 0; + box-sizing: border-box; + width: 23px; + height: 23px; + outline: none; + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 4px; + cursor: pointer; + transition: inherit; + + > .check { + margin: auto; + opacity: 0; + color: var(--fgOnAccent); + font-size: 13px; + transform: scale(0.5); + transition: all 0.2s ease; + } + } + + &:hover { + > .button { + border-color: var(--inputBorderHover) !important; + } + } + + > .label { + margin-left: 12px; + margin-top: 2px; + display: block; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + cursor: pointer; + transition: inherit; + } + + > .caption { + margin: 8px 0 0 0; + color: var(--fgTransparentWeak); + font-size: 0.85em; + + &:empty { + display: none; + } + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent) !important; + border-color: var(--accent) !important; + + > .check { + opacity: 1; + transform: scale(1); + } + } + } +} +</style> diff --git a/packages/client/src/components/form/folder.vue b/packages/client/src/components/form/folder.vue index 1b960657d7..a9d8bd97b8 100644 --- a/packages/client/src/components/form/folder.vue +++ b/packages/client/src/components/form/folder.vue @@ -9,13 +9,13 @@ <i v-else class="fas fa-angle-down icon"></i> </span> </div> - <keep-alive> + <KeepAlive> <div v-if="openedAtLeastOnce" v-show="opened" class="body"> <MkSpacer :margin-min="14" :margin-max="22"> <slot></slot> </MkSpacer> </div> - </keep-alive> + </KeepAlive> </div> </template> diff --git a/packages/client/src/components/form/group.vue b/packages/client/src/components/form/group.vue deleted file mode 100644 index 1e8376ca44..0000000000 --- a/packages/client/src/components/form/group.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> -<div v-sticky-container class="adfeebaf _formBlock"> - <div class="label"><slot name="label"></slot></div> - <div class="main _formRoot"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ -}); -</script> - -<style lang="scss" scoped> -.adfeebaf { - padding: 24px 24px; - border: solid 1px var(--divider); - border-radius: var(--radius); - - > .label { - font-weight: bold; - padding: 0 0 16px 0; - - &:empty { - display: none; - } - } - - > .main { - - } -} -</style> diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue index 7165671af3..ec1ad20de3 100644 --- a/packages/client/src/components/form/input.vue +++ b/packages/client/src/components/form/input.vue @@ -3,7 +3,8 @@ <div class="label" @click="focus"><slot name="label"></slot></div> <div class="input" :class="{ inline, disabled, focused }"> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> - <input ref="inputEl" + <input + ref="inputEl" v-model="v" v-adaptive-border :type="type" @@ -32,176 +33,118 @@ </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; +import MkButton from '@/components/ui/button.vue'; +import { useInterval } from '@/scripts/use-interval'; -export default defineComponent({ - components: { - MkButton, - }, - - props: { - modelValue: { - required: true - }, - type: { - type: String, - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - autocomplete: { - required: false - }, - spellcheck: { - required: false - }, - step: { - required: false - }, - datalist: { - type: Array, - required: false, - }, - inline: { - type: Boolean, - required: false, - default: false - }, - debounce: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['change', 'keydown', 'enter', 'update:modelValue'], - - setup(props, context) { - const { modelValue, type, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const id = Math.random().toString(); // TODO: uuid? - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref<HTMLElement>(); - const prefixEl = ref<HTMLElement>(); - const suffixEl = ref<HTMLElement>(); +const props = defineProps<{ + modelValue: string | number; + type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time'; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + pattern?: string; + placeholder?: string; + autofocus?: boolean; + autocomplete?: boolean; + spellcheck?: boolean; + step?: any; + datalist?: string[]; + inline?: boolean; + debounce?: boolean; + manualSave?: boolean; + small?: boolean; + large?: boolean; +}>(); - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - const onKeydown = (ev: KeyboardEvent) => { - context.emit('keydown', ev); +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'keydown', _ev: KeyboardEvent): void; + (ev: 'enter'): void; + (ev: 'update:modelValue', value: string | number): void; +}>(); - if (ev.code === 'Enter') { - context.emit('enter'); - } - }; +const { modelValue, type, autofocus } = toRefs(props); +const v = ref(modelValue.value); +const id = Math.random().toString(); // TODO: uuid? +const focused = ref(false); +const changed = ref(false); +const invalid = ref(false); +const filled = computed(() => v.value !== '' && v.value != null); +const inputEl = ref<HTMLElement>(); +const prefixEl = ref<HTMLElement>(); +const suffixEl = ref<HTMLElement>(); +const height = + props.small ? 38 : + props.large ? 42 : + 40; - const updated = () => { - changed.value = false; - if (type?.value === 'number') { - context.emit('update:modelValue', parseFloat(v.value)); - } else { - context.emit('update:modelValue', v.value); - } - }; +const focus = () => inputEl.value.focus(); +const onInput = (ev: KeyboardEvent) => { + changed.value = true; + emit('change', ev); +}; +const onKeydown = (ev: KeyboardEvent) => { + emit('keydown', ev); - const debouncedUpdated = debounce(1000, updated); + if (ev.code === 'Enter') { + emit('enter'); + } +}; - watch(modelValue, newValue => { - v.value = newValue; - }); +const updated = () => { + changed.value = false; + if (type.value === 'number') { + emit('update:modelValue', parseFloat(v.value)); + } else { + emit('update:modelValue', v.value); + } +}; - watch(v, newValue => { - if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } - } +const debouncedUpdated = debounce(1000, updated); - invalid.value = inputEl.value.validity.badInput; - }); +watch(modelValue, newValue => { + v.value = newValue; +}); - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } +watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = window.setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); + invalid.value = inputEl.value.validity.badInput; +}); - onUnmounted(() => { - window.clearInterval(clock); - }); - }); - }); +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する +useInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } +}, 100, { + immediate: true, + afterMounted: true, +}); - return { - id, - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - focus, - onInput, - onKeydown, - updated, - }; - }, +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); }); </script> @@ -228,14 +171,13 @@ export default defineComponent({ } > .input { - $height: 42px; position: relative; > input { appearance: none; -webkit-appearance: none; display: block; - height: $height; + height: v-bind("height + 'px'"); width: 100%; margin: 0; padding: 0 12px; @@ -265,7 +207,7 @@ export default defineComponent({ top: 0; padding: 0 12px; font-size: 1em; - height: $height; + height: v-bind("height + 'px'"); pointer-events: none; &:empty { diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue index 2becbec6f3..b4d39507e3 100644 --- a/packages/client/src/components/form/radio.vue +++ b/packages/client/src/components/form/radio.vue @@ -7,7 +7,8 @@ :aria-disabled="disabled" @click="toggle" > - <input type="radio" + <input + type="radio" :disabled="disabled" > <span class="button"> @@ -23,27 +24,27 @@ import { defineComponent } from 'vue'; export default defineComponent({ props: { modelValue: { - required: false + required: false, }, value: { - required: false + required: false, }, disabled: { type: Boolean, - default: false - } + default: false, + }, }, computed: { checked(): boolean { return this.modelValue === this.value; - } + }, }, methods: { toggle() { if (this.disabled) return; this.$emit('update:modelValue', this.value); - } - } + }, + }, }); </script> @@ -53,7 +54,8 @@ export default defineComponent({ display: inline-block; text-align: left; cursor: pointer; - padding: 10px 12px; + padding: 9px 12px; + min-width: 60px; background-color: var(--panel); background-clip: padding-box !important; border: solid 1px var(--panel); diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue index a52acae9e1..bde4a8fb00 100644 --- a/packages/client/src/components/form/radios.vue +++ b/packages/client/src/components/form/radios.vue @@ -4,11 +4,11 @@ import MkRadio from './radio.vue'; export default defineComponent({ components: { - MkRadio + MkRadio, }, props: { modelValue: { - required: false + required: false, }, }, data() { @@ -19,7 +19,7 @@ export default defineComponent({ watch: { value() { this.$emit('update:modelValue', this.value); - } + }, }, render() { let options = this.$slots.default(); @@ -30,25 +30,25 @@ export default defineComponent({ if (options.length === 1 && options[0].props == null) options = options[0].children; return h('div', { - class: 'novjtcto' + class: 'novjtcto', }, [ ...(label ? [h('div', { - class: 'label' + class: 'label', }, [label])] : []), h('div', { - class: 'body' + class: 'body', }, options.map(option => h(MkRadio, { - key: option.key, - value: option.props.value, - modelValue: this.value, - 'onUpdate:modelValue': value => this.value = value, - }, option.children)), + key: option.key, + value: option.props.value, + modelValue: this.value, + 'onUpdate:modelValue': value => this.value = value, + }, option.children)), ), ...(caption ? [h('div', { - class: 'caption' + class: 'caption', }, [caption])] : []), ]); - } + }, }); </script> @@ -65,9 +65,9 @@ export default defineComponent({ } > .body { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - grid-gap: 12px; + display: flex; + gap: 12px; + flex-wrap: wrap; } > .caption { diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue index 07f2c23124..ebec482d84 100644 --- a/packages/client/src/components/form/range.vue +++ b/packages/client/src/components/form/range.vue @@ -1,164 +1,142 @@ <template> <div class="timctyfi" :class="{ disabled }"> <div class="label"><slot name="label"></slot></div> - <div v-panel class="body"> + <div v-adaptive-border class="body"> <div ref="containerEl" class="container"> <div class="track"> - <div class="highlight" :style="{ width: (steppedValue * 100) + '%' }"></div> + <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> </div> - <div v-if="steps" class="ticks"> + <div v-if="steps && showTicks" class="ticks"> <div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div> </div> <div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div> </div> </div> + <div class="caption"><slot name="caption"></slot></div> </div> </template> -<script lang="ts"> -import { computed, defineAsyncComponent, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue'; +<script lang="ts" setup> +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'; import * as os from '@/os'; -export default defineComponent({ - props: { - modelValue: { - type: Number, - required: false, - default: 0 - }, - disabled: { - type: Boolean, - required: false, - default: false - }, - min: { - type: Number, - required: false, - default: 0 - }, - max: { - type: Number, - required: false, - default: 100 - }, - step: { - type: Number, - required: false, - default: 1 - }, - autofocus: { - type: Boolean, - required: false - }, - textConverter: { - type: Function, - required: false, - default: (v) => v.toString(), - }, - }, +const props = withDefaults(defineProps<{ + modelValue: number; + disabled?: boolean; + min: number; + max: number; + step?: number; + textConverter?: (value: number) => string, + showTicks?: boolean; +}>(), { + step: 1, + textConverter: (v) => v.toString(), +}); - setup(props, context) { - const containerEl = ref<HTMLElement>(); - const thumbEl = ref<HTMLElement>(); +const emit = defineEmits<{ + (ev: 'update:modelValue', value: number): void; +}>(); - const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); - const steppedValue = computed(() => { - if (props.step) { - const step = props.step / (props.max - props.min); - return (step * Math.round(rawValue.value / step)); - } else { - return rawValue.value; - } - }); - const finalValue = computed(() => { - return (steppedValue.value * (props.max - props.min)) + props.min; - }); - watch(finalValue, () => { - context.emit('update:modelValue', finalValue.value); - }); +const containerEl = ref<HTMLElement>(); +const thumbEl = ref<HTMLElement>(); - const thumbWidth = computed(() => { - if (thumbEl.value == null) return 0; - return thumbEl.value!.offsetWidth; - }); - const thumbPosition = ref(0); - const calcThumbPosition = () => { - if (containerEl.value == null) { - thumbPosition.value = 0; - } else { - thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedValue.value; - } - }; - watch([steppedValue, containerEl], calcThumbPosition); - onMounted(() => { - const ro = new ResizeObserver((entries, observer) => { - calcThumbPosition(); - }); - ro.observe(containerEl.value); - onUnmounted(() => { - ro.disconnect(); - }); - }); +const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); +const steppedRawValue = computed(() => { + if (props.step) { + const step = props.step / (props.max - props.min); + return (step * Math.round(rawValue.value / step)); + } else { + return rawValue.value; + } +}); +const finalValue = computed(() => { + if (Number.isInteger(props.step)) { + return Math.round((steppedRawValue.value * (props.max - props.min)) + props.min); + } else { + return (steppedRawValue.value * (props.max - props.min)) + props.min; + } +}); - const steps = computed(() => { - if (props.step) { - return (props.max - props.min) / props.step; - } else { - return 0; - } - }); +const thumbWidth = computed(() => { + if (thumbEl.value == null) return 0; + return thumbEl.value!.offsetWidth; +}); +const thumbPosition = ref(0); +const calcThumbPosition = () => { + if (containerEl.value == null) { + thumbPosition.value = 0; + } else { + thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedRawValue.value; + } +}; +watch([steppedRawValue, containerEl], calcThumbPosition); - const onMousedown = (ev: MouseEvent | TouchEvent) => { - ev.preventDefault(); +let ro: ResizeObserver | undefined; - const tooltipShowing = ref(true); - os.popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { - showing: tooltipShowing, - text: computed(() => { - return props.textConverter(finalValue.value); - }), - targetElement: thumbEl, - }, {}, 'closed'); +onMounted(() => { + ro = new ResizeObserver((entries, observer) => { + calcThumbPosition(); + }); + ro.observe(containerEl.value); +}); - const style = document.createElement('style'); - style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); - document.head.appendChild(style); +onUnmounted(() => { + if (ro) ro.disconnect(); +}); - const onDrag = (ev: MouseEvent | TouchEvent) => { - ev.preventDefault(); - const containerRect = containerEl.value!.getBoundingClientRect(); - const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; - const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2)); - rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value))); - }; +const steps = computed(() => { + if (props.step) { + return (props.max - props.min) / props.step; + } else { + return 0; + } +}); - const onMouseup = () => { - document.head.removeChild(style); - tooltipShowing.value = false; - window.removeEventListener('mousemove', onDrag); - window.removeEventListener('touchmove', onDrag); - window.removeEventListener('mouseup', onMouseup); - window.removeEventListener('touchend', onMouseup); - }; +const onMousedown = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); - window.addEventListener('mousemove', onDrag); - window.addEventListener('touchmove', onDrag); - window.addEventListener('mouseup', onMouseup, { once: true }); - window.addEventListener('touchend', onMouseup, { once: true }); - }; + const tooltipShowing = ref(true); + os.popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { + showing: tooltipShowing, + text: computed(() => { + return props.textConverter(finalValue.value); + }), + targetElement: thumbEl, + }, {}, 'closed'); - return { - rawValue, - finalValue, - steppedValue, - onMousedown, - containerEl, - thumbEl, - thumbPosition, - steps, - }; - }, -}); + const style = document.createElement('style'); + style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); + document.head.appendChild(style); + + const onDrag = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); + const containerRect = containerEl.value!.getBoundingClientRect(); + const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; + const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2)); + rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value))); + }; + + let beforeValue = finalValue.value; + + const onMouseup = () => { + document.head.removeChild(style); + tooltipShowing.value = false; + window.removeEventListener('mousemove', onDrag); + window.removeEventListener('touchmove', onDrag); + window.removeEventListener('mouseup', onMouseup); + window.removeEventListener('touchend', onMouseup); + + // 値が変わってたら通知 + if (beforeValue !== finalValue.value) { + emit('update:modelValue', finalValue.value); + } + }; + + window.addEventListener('mousemove', onDrag); + window.addEventListener('touchmove', onDrag); + window.addEventListener('mouseup', onMouseup, { once: true }); + window.addEventListener('touchend', onMouseup, { once: true }); +}; </script> <style lang="scss" scoped> @@ -191,7 +169,9 @@ export default defineComponent({ $thumbWidth: 20px; > .body { - padding: 12px; + padding: 10px 12px; + background: var(--panel); + border: solid 1px var(--panel); border-radius: 6px; > .container { @@ -209,7 +189,7 @@ export default defineComponent({ height: 3px; background: rgba(0, 0, 0, 0.1); border-radius: 999px; - overflow: clip; + overflow: hidden; overflow: clip; > .highlight { position: absolute; @@ -218,7 +198,7 @@ export default defineComponent({ height: 100%; background: var(--accent); opacity: 0.5; - transition: width 0.2s cubic-bezier(0,0,0,1); + //transition: width 0.2s cubic-bezier(0,0,0,1); } } @@ -251,7 +231,7 @@ export default defineComponent({ cursor: grab; background: var(--accent); border-radius: 999px; - transition: left 0.2s cubic-bezier(0,0,0,1); + //transition: left 0.2s cubic-bezier(0,0,0,1); &:hover { background: var(--accentLighten); diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue index 87196027a8..fe8c08cd6c 100644 --- a/packages/client/src/components/form/select.vue +++ b/packages/client/src/components/form/select.vue @@ -3,7 +3,8 @@ <div class="label" @click="focus"><slot name="label"></slot></div> <div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick"> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> - <select ref="inputEl" + <select + ref="inputEl" v-model="v" v-adaptive-border class="select" @@ -25,178 +26,139 @@ </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; -export default defineComponent({ - components: { - MkButton, - }, +const props = defineProps<{ + modelValue: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + placeholder?: string; + autofocus?: boolean; + inline?: boolean; + manualSave?: boolean; + small?: boolean; + large?: boolean; +}>(); - props: { - modelValue: { - required: true - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'update:modelValue', value: string): void; +}>(); - emits: ['change', 'update:modelValue'], +const slots = useSlots(); - setup(props, context) { - const { modelValue, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - const prefixEl = ref(null); - const suffixEl = ref(null); - const container = ref(null); +const { modelValue, autofocus } = toRefs(props); +const v = ref(modelValue.value); +const focused = ref(false); +const changed = ref(false); +const invalid = ref(false); +const filled = computed(() => v.value !== '' && v.value != null); +const inputEl = ref(null); +const prefixEl = ref(null); +const suffixEl = ref(null); +const container = ref(null); +const height = + props.small ? 38 : + props.large ? 42 : + 40; - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; +const focus = () => inputEl.value.focus(); +const onInput = (ev) => { + changed.value = true; + emit('change', ev); +}; - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; +const updated = () => { + changed.value = false; + emit('update:modelValue', v.value); +}; - watch(modelValue, newValue => { - v.value = newValue; - }); +watch(modelValue, newValue => { + v.value = newValue; +}); - watch(v, newValue => { - if (!props.manualSave) { - updated(); - } +watch(v, newValue => { + if (!props.manualSave) { + updated(); + } - invalid.value = inputEl.value.validity.badInput; - }); + invalid.value = inputEl.value.validity.badInput; +}); - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する +useInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } +}, 100, { + immediate: true, + afterMounted: true, +}); - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = window.setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); +}); - onUnmounted(() => { - window.clearInterval(clock); - }); - }); - }); +const onClick = (ev: MouseEvent) => { + focused.value = true; - const onClick = (ev: MouseEvent) => { - focused.value = true; + const menu = []; + let options = slots.default!(); - const menu = []; - let options = context.slots.default(); + const pushOption = (option: VNode) => { + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + }; - const pushOption = (option: VNode) => { + const scanOptions = (options: VNode[]) => { + for (const vnode of options) { + if (vnode.type === 'optgroup') { + const optgroup = vnode; menu.push({ - text: option.children, - active: v.value === option.props.value, - action: () => { - v.value = option.props.value; - }, + type: 'label', + text: optgroup.props.label, }); - }; - - const scanOptions = (options: VNode[]) => { - for (const vnode of options) { - if (vnode.type === 'optgroup') { - const optgroup = vnode; - menu.push({ - type: 'label', - text: optgroup.props.label, - }); - scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - scanOptions(fragment.children); - } else { - const option = vnode; - pushOption(option); - } - } - }; - - scanOptions(options); + scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + scanOptions(fragment.children); + } else { + const option = vnode; + pushOption(option); + } + } + }; - os.popupMenu(menu, container.value, { - width: container.value.offsetWidth, - }).then(() => { - focused.value = false; - }); - }; + scanOptions(options); - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - container, - focus, - onInput, - onClick, - updated, - }; - }, -}); + os.popupMenu(menu, container.value, { + width: container.value.offsetWidth, + }).then(() => { + focused.value = false; + }); +}; </script> <style lang="scss" scoped> @@ -222,7 +184,6 @@ export default defineComponent({ } > .input { - $height: 42px; position: relative; cursor: pointer; @@ -236,7 +197,7 @@ export default defineComponent({ appearance: none; -webkit-appearance: none; display: block; - height: $height; + height: v-bind("height + 'px'"); width: 100%; margin: 0; padding: 0 12px; @@ -253,6 +214,7 @@ export default defineComponent({ cursor: pointer; transition: border-color 0.1s ease-out; pointer-events: none; + user-select: none; } > .prefix, @@ -264,7 +226,7 @@ export default defineComponent({ top: 0; padding: 0 12px; font-size: 1em; - height: $height; + height: v-bind("height + 'px'"); pointer-events: none; &:empty { diff --git a/packages/client/src/components/form/split.vue b/packages/client/src/components/form/split.vue index 676b293967..301a8a84e5 100644 --- a/packages/client/src/components/form/split.vue +++ b/packages/client/src/components/form/split.vue @@ -6,9 +6,9 @@ <script lang="ts" setup> const props = withDefaults(defineProps<{ - minWidth: number; + minWidth?: number; }>(), { - minWidth: 210, + minWidth: 210, }); const minWidth = props.minWidth + 'px'; diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue index fadb770aee..fead163552 100644 --- a/packages/client/src/components/form/switch.vue +++ b/packages/client/src/components/form/switch.vue @@ -1,6 +1,6 @@ <template> <div - class="ziffeoms" + class="ziffeomt" :class="{ disabled, checked }" > <input @@ -9,8 +9,8 @@ :disabled="disabled" @keydown.enter="toggle" > - <span ref="button" v-adaptive-border v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> - <i class="check fas fa-check"></i> + <span ref="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> + <div class="knob"></div> </span> <span class="label"> <!-- TODO: 無名slotの方は廃止 --> @@ -23,7 +23,6 @@ <script lang="ts" setup> import { toRefs, Ref } from 'vue'; import * as os from '@/os'; -import Ripple from '@/components/ripple.vue'; const props = defineProps<{ modelValue: boolean | Ref<boolean>; @@ -41,16 +40,13 @@ const toggle = () => { emit('update:modelValue', !checked.value); if (!checked.value) { - const rect = button.getBoundingClientRect(); - const x = rect.left + (button.offsetWidth / 2); - const y = rect.top + (button.offsetHeight / 2); - os.popup(Ripple, { x, y, particle: false }, {}, 'end'); + } }; </script> <style lang="scss" scoped> -.ziffeoms { +.ziffeomt { position: relative; display: flex; transition: all 0.2s ease; @@ -73,21 +69,25 @@ const toggle = () => { flex-shrink: 0; margin: 0; box-sizing: border-box; - width: 23px; + width: 32px; height: 23px; outline: none; - background: var(--panel); - border: solid 1px var(--panel); - border-radius: 4px; + background: var(--swutchOffBg); + background-clip: content-box; + border: solid 1px var(--swutchOffBg); + border-radius: 999px; cursor: pointer; transition: inherit; + user-select: none; - > .check { - margin: auto; - opacity: 0; - color: var(--fgOnAccent); - font-size: 13px; - transform: scale(0.5); + > .knob { + position: absolute; + top: 3px; + left: 3px; + width: 15px; + height: 15px; + background: var(--swutchOffFg); + border-radius: 999px; transition: all 0.2s ease; } } @@ -130,12 +130,12 @@ const toggle = () => { &.checked { > .button { - background-color: var(--accent) !important; - border-color: var(--accent) !important; + background-color: var(--swutchOnBg) !important; + border-color: var(--swutchOnBg) !important; - > .check { - opacity: 1; - transform: scale(1); + > .knob { + left: 12px; + background: var(--swutchOnFg); } } } diff --git a/packages/client/src/components/formula-core.vue b/packages/client/src/components/formula-core.vue index 49a61ab80e..8db8932fcd 100644 --- a/packages/client/src/components/formula-core.vue +++ b/packages/client/src/components/formula-core.vue @@ -1,4 +1,4 @@ - +<!-- eslint-disable vue/no-v-html --> <template> <div v-if="block" v-html="compiledFormula"></div> <span v-else v-html="compiledFormula"></span> diff --git a/packages/client/src/components/formula.vue b/packages/client/src/components/formula.vue index fbb40bace7..431b4e6c3e 100644 --- a/packages/client/src/components/formula.vue +++ b/packages/client/src/components/formula.vue @@ -3,7 +3,8 @@ </template> <script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os'; +import { defineComponent, defineAsyncComponent } from 'vue'; +import * as os from '@/os'; export default defineComponent({ components: { diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue index 5287d59b3e..c7cf12e8c8 100644 --- a/packages/client/src/components/global/a.vue +++ b/packages/client/src/components/global/a.vue @@ -5,13 +5,13 @@ </template> <script lang="ts" setup> +import { inject } from 'vue'; import * as os from '@/os'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { router } from '@/router'; import { url } from '@/config'; import { popout as popout_ } from '@/scripts/popout'; import { i18n } from '@/i18n'; -import { MisskeyNavigator } from '@/scripts/navigate'; +import { useRouter } from '@/router'; const props = withDefaults(defineProps<{ to: string; @@ -22,15 +22,16 @@ const props = withDefaults(defineProps<{ behavior: null, }); -const mkNav = new MisskeyNavigator(); +const router = useRouter(); const active = $computed(() => { if (props.activeClass == null) return false; const resolved = router.resolve(props.to); - if (resolved.path === router.currentRoute.value.path) return true; - if (resolved.name == null) return false; + if (resolved == null) return false; + if (resolved.route.path === router.currentRoute.value.path) return true; + if (resolved.route.name == null) return false; if (router.currentRoute.value.name == null) return false; - return resolved.name === router.currentRoute.value.name; + return resolved.route.name === router.currentRoute.value.name; }); function onContextmenu(ev) { @@ -44,31 +45,25 @@ function onContextmenu(ev) { text: i18n.ts.openInWindow, action: () => { os.pageWindow(props.to); - } - }, mkNav.sideViewHook ? { - icon: 'fas fa-columns', - text: i18n.ts.openInSideView, - action: () => { - if (mkNav.sideViewHook) mkNav.sideViewHook(props.to); - } - } : undefined, { + }, + }, { icon: 'fas fa-expand-alt', text: i18n.ts.showInPage, action: () => { router.push(props.to); - } + }, }, null, { icon: 'fas fa-external-link-alt', text: i18n.ts.openInNewTab, action: () => { window.open(props.to, '_blank'); - } + }, }, { icon: 'fas fa-link', text: i18n.ts.copyLink, action: () => { copyToClipboard(`${url}${props.to}`); - } + }, }], ev); } @@ -98,6 +93,6 @@ function nav() { } } - mkNav.push(props.to); + router.push(props.to); } </script> diff --git a/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/emoji.vue index 0075e0867d..23cb649f7a 100644 --- a/packages/client/src/components/global/emoji.vue +++ b/packages/client/src/components/global/emoji.vue @@ -1,4 +1,4 @@ -<template> +char2filePath<template> <img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/> <img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/> <span v-else-if="char && useOsNativeEmojis">{{ char }}</span> @@ -8,7 +8,7 @@ <script lang="ts"> import { computed, defineComponent, ref, watch } from 'vue'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; -import { twemojiSvgBase } from '@/scripts/twemoji-base'; +import { char2filePath } from '@/scripts/twemoji-base'; import { defaultStore } from '@/store'; import { instance } from '@/instance'; @@ -45,10 +45,7 @@ export default defineComponent({ const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : null); const url = computed(() => { if (char.value) { - let codes = Array.from(char.value).map(x => x.codePointAt(0).toString(16)); - if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); - codes = codes.filter(x => x && x.length); - return `${twemojiSvgBase}/${codes.join('-')}.svg`; + return char2filePath(char.value); } else { return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(customEmoji.value.url) diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue deleted file mode 100644 index 63db19a520..0000000000 --- a/packages/client/src/components/global/header.vue +++ /dev/null @@ -1,361 +0,0 @@ -<template> -<div ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick"> - <template v-if="info"> - <div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup"> - <MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> - <i v-else-if="info.icon" class="icon" :class="info.icon"></i> - - <div class="title"> - <MkUserName v-if="info.userName" :user="info.userName" :nowrap="true" class="title"/> - <div v-else-if="info.title" class="title">{{ info.title }}</div> - <div v-if="!narrow && info.subtitle" class="subtitle"> - {{ info.subtitle }} - </div> - <div v-if="narrow && hasTabs" class="subtitle activeTab"> - {{ info.tabs.find(tab => tab.active)?.title }} - <i class="chevron fas fa-chevron-down"></i> - </div> - </div> - </div> - <div v-if="!narrow || hideTitle" class="tabs"> - <button v-for="tab in info.tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick"> - <i v-if="tab.icon" class="icon" :class="tab.icon"></i> - <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> - </button> - </div> - </template> - <div class="buttons right"> - <template v-if="info && info.actions && !narrow"> - <template v-for="action in info.actions"> - <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> - <button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> - </template> - </template> - <button v-if="shouldShowMenu" v-tooltip="$ts.menu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag"><i class="fas fa-ellipsis-h"></i></button> - </div> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue'; -import tinycolor from 'tinycolor2'; -import { popupMenu } from '@/os'; -import { url } from '@/config'; -import { scrollToTop } from '@/scripts/scroll'; -import MkButton from '@/components/ui/button.vue'; -import { i18n } from '@/i18n'; -import { globalEvents } from '@/events'; - -export default defineComponent({ - components: { - MkButton - }, - - props: { - info: { - type: Object as PropType<{ - actions?: {}[]; - tabs?: {}[]; - }>, - required: true - }, - menu: { - required: false - }, - thin: { - required: false, - default: false - }, - }, - - setup(props) { - const el = ref<HTMLElement>(null); - const bg = ref(null); - const narrow = ref(false); - const height = ref(0); - const hasTabs = computed(() => { - return props.info.tabs && props.info.tabs.length > 0; - }); - const shouldShowMenu = computed(() => { - if (props.info == null) return false; - if (props.info.actions != null && narrow.value) return true; - if (props.info.menu != null) return true; - if (props.info.share != null) return true; - if (props.menu != null) return true; - return false; - }); - - const share = () => { - navigator.share({ - url: url + props.info.path, - ...props.info.share, - }); - }; - - const showMenu = (ev: MouseEvent) => { - let menu = props.info.menu ? props.info.menu() : []; - if (narrow.value && props.info.actions) { - menu = [...props.info.actions.map(x => ({ - text: x.text, - icon: x.icon, - action: x.handler - })), menu.length > 0 ? null : undefined, ...menu]; - } - if (props.info.share) { - if (menu.length > 0) menu.push(null); - menu.push({ - text: i18n.ts.share, - icon: 'fas fa-share-alt', - action: share - }); - } - if (props.menu) { - if (menu.length > 0) menu.push(null); - menu = menu.concat(props.menu); - } - popupMenu(menu, ev.currentTarget ?? ev.target); - }; - - const showTabsPopup = (ev: MouseEvent) => { - if (!hasTabs.value) return; - if (!narrow.value) return; - ev.preventDefault(); - ev.stopPropagation(); - const menu = props.info.tabs.map(tab => ({ - text: tab.title, - icon: tab.icon, - action: tab.onClick, - })); - popupMenu(menu, ev.currentTarget ?? ev.target); - }; - - const preventDrag = (ev: TouchEvent) => { - ev.stopPropagation(); - }; - - const onClick = () => { - scrollToTop(el.value, { behavior: 'smooth' }); - }; - - const calcBg = () => { - const rawBg = props.info?.bg || 'var(--bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); - tinyBg.setAlpha(0.85); - bg.value = tinyBg.toRgbString(); - }; - - onMounted(() => { - calcBg(); - globalEvents.on('themeChanged', calcBg); - onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); - }); - - if (el.value.parentElement) { - narrow.value = el.value.parentElement.offsetWidth < 500; - const ro = new ResizeObserver((entries, observer) => { - if (el.value) { - narrow.value = el.value.parentElement.offsetWidth < 500; - } - }); - ro.observe(el.value.parentElement); - onUnmounted(() => { - ro.disconnect(); - }); - } - }); - - return { - el, - bg, - narrow, - height, - hasTabs, - shouldShowMenu, - share, - showMenu, - showTabsPopup, - preventDrag, - onClick, - hideTitle: inject('shouldOmitHeaderTitle', false), - thin_: props.thin || inject('shouldHeaderThin', false) - }; - }, -}); -</script> - -<style lang="scss" scoped> -.fdidabkb { - --height: 60px; - display: flex; - position: sticky; - top: var(--stickyTop, 0); - z-index: 1000; - width: 100%; - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); - border-bottom: solid 0.5px var(--divider); - - &.thin { - --height: 50px; - - > .buttons { - > .button { - font-size: 0.9em; - } - } - } - - &.slim { - text-align: center; - - > .titleContainer { - flex: 1; - margin: 0 auto; - margin-left: var(--height); - - > *:first-child { - margin-left: auto; - } - - > *:last-child { - margin-right: auto; - } - } - } - - > .buttons { - --margin: 8px; - display: flex; - align-items: center; - height: var(--height); - margin: 0 var(--margin); - - &.right { - margin-left: auto; - } - - &:empty { - width: var(--height); - } - - > .button { - display: flex; - align-items: center; - justify-content: center; - height: calc(var(--height) - (var(--margin) * 2)); - width: calc(var(--height) - (var(--margin) * 2)); - box-sizing: border-box; - position: relative; - border-radius: 5px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &.highlighted { - color: var(--accent); - } - } - - > .fullButton { - & + .fullButton { - margin-left: 12px; - } - } - } - - > .titleContainer { - display: flex; - align-items: center; - max-width: 400px; - overflow: auto; - white-space: nowrap; - text-align: left; - font-weight: bold; - flex-shrink: 0; - margin-left: 24px; - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: bottom; - margin: 0 8px; - pointer-events: none; - } - - > .icon { - margin-right: 8px; - } - - > .title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.1; - - > .subtitle { - opacity: 0.6; - font-size: 0.8em; - font-weight: normal; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &.activeTab { - text-align: center; - - > .chevron { - display: inline-block; - margin-left: 6px; - } - } - } - } - } - - > .tabs { - margin-left: 16px; - font-size: 0.8em; - overflow: auto; - white-space: nowrap; - - > .tab { - display: inline-block; - position: relative; - padding: 0 10px; - height: 100%; - font-weight: normal; - opacity: 0.7; - - &:hover { - opacity: 1; - } - - &.active { - opacity: 1; - - &:after { - content: ""; - display: block; - position: absolute; - bottom: 0; - left: 0; - right: 0; - margin: 0 auto; - width: 100%; - height: 3px; - background: var(--accent); - } - } - - > .icon + .title { - margin-left: 8px; - } - } - } -} -</style> diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/page-header.vue new file mode 100644 index 0000000000..766f9b6b6a --- /dev/null +++ b/packages/client/src/components/global/page-header.vue @@ -0,0 +1,346 @@ +<template> +<div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick"> + <template v-if="metadata"> + <div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup"> + <MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/> + <i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i> + + <div class="title"> + <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/> + <div v-else-if="metadata.title" class="title">{{ metadata.title }}</div> + <div v-if="!narrow && metadata.subtitle" class="subtitle"> + {{ metadata.subtitle }} + </div> + <div v-if="narrow && hasTabs" class="subtitle activeTab"> + {{ tabs.find(tab => tab.key === props.tab)?.title }} + <i class="chevron fas fa-chevron-down"></i> + </div> + </div> + </div> + <div v-if="!narrow || hideTitle" class="tabs"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + <div ref="tabHighlightEl" class="highlight"></div> + </div> + </template> + <div class="buttons right"> + <template v-for="action in actions"> + <button v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, ref, inject, watch, shallowReactive, nextTick, reactive } from 'vue'; +import tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { scrollToTop } from '@/scripts/scroll'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; +import { injectPageMetadata } from '@/scripts/page-metadata'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; + +const props = defineProps<{ + tabs?: Tab[]; + tab?: string; + actions?: { + text: string; + icon: string; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + +const metadata = injectPageMetadata(); + +const hideTitle = inject('shouldOmitHeaderTitle', false); +const thin_ = props.thin || inject('shouldHeaderThin', false); + +const el = $ref<HTMLElement | null>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); +const bg = ref(null); +let narrow = $ref(false); +const height = ref(0); +const hasTabs = $computed(() => props.tabs && props.tabs.length > 0); +const hasActions = $computed(() => props.actions && props.actions.length > 0); +const show = $computed(() => { + return !hideTitle || hasTabs || hasActions; +}); + +const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs) return; + if (!narrow) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, + })); + popupMenu(menu, ev.currentTarget ?? ev.target); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + scrollToTop(el, { behavior: 'smooth' }); +}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + +const calcBg = () => { + const rawBg = metadata?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); +}; + +let ro: ResizeObserver | null; + +onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); + + if (el && el.parentElement) { + narrow = el.parentElement.offsetWidth < 500; + ro = new ResizeObserver((entries, observer) => { + if (el.parentElement && document.body.contains(el)) { + narrow = el.parentElement.offsetWidth < 500; + } + }); + ro.observe(el.parentElement); + } +}); + +onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); + if (ro) ro.disconnect(); +}); +</script> + +<style lang="scss" scoped> +.fdidabkb { + --height: 55px; + display: flex; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-bottom: solid 0.5px var(--divider); + contain: strict; + height: var(--height); + + &.thin { + --height: 45px; + + > .buttons { + > .button { + font-size: 0.9em; + } + } + } + + &.slim { + text-align: center; + + > .titleContainer { + flex: 1; + margin: 0 auto; + margin-left: var(--height); + + > *:first-child { + margin-left: auto; + } + + > *:last-child { + margin-right: auto; + } + } + } + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + max-width: 400px; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + width: 16px; + text-align: center; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + position: relative; + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + > .icon + .title { + margin-left: 8px; + } + } + + > .highlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: all 0.2s ease; + pointer-events: none; + } + } +} +</style> diff --git a/packages/client/src/components/global/router-view.vue b/packages/client/src/components/global/router-view.vue new file mode 100644 index 0000000000..fca2371f0d --- /dev/null +++ b/packages/client/src/components/global/router-view.vue @@ -0,0 +1,37 @@ +<template> +<KeepAlive :max="defaultStore.state.numberOfPageCache"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> +</KeepAlive> +</template> + +<script lang="ts" setup> +import { inject, nextTick, onMounted, onUnmounted, watch } from 'vue'; +import { Router } from '@/nirax'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject('router'); + +if (router == null) { + throw new Error('no router provided'); +} + +let currentPageComponent = $shallowRef(router.getCurrentComponent()); +let currentPageProps = $ref(router.getCurrentProps()); +let key = $ref(router.getCurrentKey()); + +function onChange({ route, props: newProps, key: newKey }) { + currentPageComponent = route.component; + currentPageProps = newProps; + key = newKey; +} + +router.addListener('change', onChange); + +onUnmounted(() => { + router.removeListener('change', onChange); +}); +</script> diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue index f2eda1907b..53adf07771 100644 --- a/packages/client/src/components/global/spacer.vue +++ b/packages/client/src/components/global/spacer.vue @@ -6,78 +6,61 @@ </div> </template> -<script lang="ts"> +<script lang="ts" setup> +import { inject, onMounted, onUnmounted, ref } from 'vue'; import { deviceKind } from '@/scripts/device-kind'; -import { defineComponent, inject, onMounted, onUnmounted, ref } from 'vue'; -export default defineComponent({ - props: { - contentMax: { - type: Number, - required: false, - default: null, - }, - marginMin: { - type: Number, - required: false, - default: 12, - }, - marginMax: { - type: Number, - required: false, - default: 24, - }, - }, +const props = withDefaults(defineProps<{ + contentMax?: number | null; + marginMin?: number; + marginMax?: number; +}>(), { + contentMax: null, + marginMin: 12, + marginMax: 24, +}); - setup(props, context) { - let ro: ResizeObserver; - const root = ref<HTMLElement>(); - const content = ref<HTMLElement>(); - const margin = ref(0); - const shouldSpacerMin = inject('shouldSpacerMin', false); - const adjust = (rect: { width: number; height: number; }) => { - if (shouldSpacerMin || deviceKind === 'smartphone') { - margin.value = props.marginMin; - return; - } +let ro: ResizeObserver; +let root = $ref<HTMLElement>(); +let content = $ref<HTMLElement>(); +let margin = $ref(0); +const shouldSpacerMin = inject('shouldSpacerMin', false); - if (rect.width > props.contentMax || (rect.width > 360 && window.innerWidth > 400)) { - margin.value = props.marginMax; - } else { - margin.value = props.marginMin; - } - }; +const adjust = (rect: { width: number; height: number; }) => { + if (shouldSpacerMin || deviceKind === 'smartphone') { + margin = props.marginMin; + return; + } - onMounted(() => { - ro = new ResizeObserver((entries) => { - /* iOSが対応していない - adjust({ - width: entries[0].borderBoxSize[0].inlineSize, - height: entries[0].borderBoxSize[0].blockSize, - }); - */ - adjust({ - width: root.value!.offsetWidth, - height: root.value!.offsetHeight, - }); - }); - ro.observe(root.value!); + if (rect.width > (props.contentMax ?? 0) || (rect.width > 360 && window.innerWidth > 400)) { + margin = props.marginMax; + } else { + margin = props.marginMin; + } +}; - if (props.contentMax) { - content.value!.style.maxWidth = `${props.contentMax}px`; - } +onMounted(() => { + ro = new ResizeObserver((entries) => { + /* iOSが対応していない + adjust({ + width: entries[0].borderBoxSize[0].inlineSize, + height: entries[0].borderBoxSize[0].blockSize, }); - - onUnmounted(() => { - ro.disconnect(); + */ + adjust({ + width: root!.offsetWidth, + height: root!.offsetHeight, }); + }); + ro.observe(root!); + + if (props.contentMax) { + content!.style.maxWidth = `${props.contentMax}px`; + } +}); - return { - root, - content, - margin, - }; - }, +onUnmounted(() => { + ro.disconnect(); }); </script> diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue index 89d397f082..44f4f065a6 100644 --- a/packages/client/src/components/global/sticky-container.vue +++ b/packages/client/src/components/global/sticky-container.vue @@ -1,71 +1,63 @@ <template> <div ref="rootEl"> - <slot name="header"></slot> - <div ref="bodyEl"> + <div ref="headerEl"> + <slot name="header"></slot> + </div> + <div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> <slot></slot> </div> </div> </template> <script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +// なんか動かない +//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP'); +const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP'; +</script> -export default defineComponent({ - props: { - autoSticky: { - type: Boolean, - required: false, - default: false, - }, - }, +<script lang="ts" setup> +import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue'; - setup(props, context) { - const rootEl = ref<HTMLElement>(null); - const bodyEl = ref<HTMLElement>(null); +const rootEl = $ref<HTMLElement>(); +const headerEl = $ref<HTMLElement>(); +const bodyEl = $ref<HTMLElement>(); - const calc = () => { - const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; +let headerHeight = $ref<string | undefined>(); +let childStickyTop = $ref(0); +const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0)); +provide(CURRENT_STICKY_TOP, $$(childStickyTop)); - const header = rootEl.value.children[0]; - if (header === bodyEl.value) { - bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); - } else { - bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); +const calc = () => { + childStickyTop = parentStickyTop.value + headerEl.offsetHeight; + headerHeight = headerEl.offsetHeight.toString(); +}; - if (props.autoSticky) { - header.style.setProperty('--stickyTop', currentStickyTop); - header.style.position = 'sticky'; - header.style.top = 'var(--stickyTop)'; - header.style.zIndex = '1'; - } - } - }; +const observer = new ResizeObserver(() => { + window.setTimeout(() => { + calc(); + }, 100); +}); - onMounted(() => { - calc(); +onMounted(() => { + calc(); - const observer = new MutationObserver(() => { - window.setTimeout(() => { - calc(); - }, 100); - }); + watch(parentStickyTop, calc); - observer.observe(rootEl.value, { - attributes: false, - childList: true, - subtree: false, - }); + watch($$(childStickyTop), () => { + bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`); + }, { + immediate: true, + }); - onUnmounted(() => { - observer.disconnect(); - }); - }); + headerEl.style.position = 'sticky'; + headerEl.style.top = 'var(--stickyTop, 0)'; + headerEl.style.zIndex = '1000'; + + observer.observe(headerEl); +}); - return { - rootEl, - bodyEl, - }; - }, +onUnmounted(() => { + observer.disconnect(); }); </script> diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue index a7f142f961..801490225b 100644 --- a/packages/client/src/components/global/time.vue +++ b/packages/client/src/components/global/time.vue @@ -24,14 +24,14 @@ let now = $ref(new Date()); const relative = $computed(() => { const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/; return ( - ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : - ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : - ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : - ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : - ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : - ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : - ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : - ago >= -1 ? i18n.ts._ago.justNow : + ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : + ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : + ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : + ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : + ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : + ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? i18n.ts._ago.justNow : i18n.ts._ago.future); }); @@ -50,7 +50,7 @@ if (props.mode === 'relative' || props.mode === 'detail') { tickId = window.requestAnimationFrame(tick); onUnmounted(() => { - window.clearTimeout(tickId); + window.cancelAnimationFrame(tickId); }); } </script> diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/img-with-blurhash.vue index 06ad764403..80d7c201a4 100644 --- a/packages/client/src/components/img-with-blurhash.vue +++ b/packages/client/src/components/img-with-blurhash.vue @@ -11,7 +11,7 @@ import { decode } from 'blurhash'; const props = withDefaults(defineProps<{ src?: string | null; - hash: string; + hash?: string; alt?: string; title?: string | null; size?: number; diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index 26bac63245..aa8a591e51 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -10,15 +10,17 @@ import MkEllipsis from './global/ellipsis.vue'; import MkTime from './global/time.vue'; import MkUrl from './global/url.vue'; import I18n from './global/i18n'; +import RouterView from './global/router-view.vue'; import MkLoading from './global/loading.vue'; import MkError from './global/error.vue'; import MkAd from './global/ad.vue'; -import MkHeader from './global/header.vue'; +import MkPageHeader from './global/page-header.vue'; import MkSpacer from './global/spacer.vue'; import MkStickyContainer from './global/sticky-container.vue'; export default function(app: App) { app.component('I18n', I18n); + app.component('RouterView', RouterView); app.component('Mfm', Mfm); app.component('MkA', MkA); app.component('MkAcct', MkAcct); @@ -31,7 +33,7 @@ export default function(app: App) { app.component('MkLoading', MkLoading); app.component('MkError', MkError); app.component('MkAd', MkAd); - app.component('MkHeader', MkHeader); + app.component('MkPageHeader', MkPageHeader); app.component('MkSpacer', MkSpacer); app.component('MkStickyContainer', MkStickyContainer); } @@ -39,6 +41,7 @@ export default function(app: App) { declare module '@vue/runtime-core' { export interface GlobalComponents { I18n: typeof I18n; + RouterView: typeof RouterView; Mfm: typeof Mfm; MkA: typeof MkA; MkAcct: typeof MkAcct; @@ -51,7 +54,7 @@ declare module '@vue/runtime-core' { MkLoading: typeof MkLoading; MkError: typeof MkError; MkAd: typeof MkAd; - MkHeader: typeof MkHeader; + MkPageHeader: typeof MkPageHeader; MkSpacer: typeof MkSpacer; MkStickyContainer: typeof MkStickyContainer; } diff --git a/packages/client/src/components/instance-card-mini.vue b/packages/client/src/components/instance-card-mini.vue new file mode 100644 index 0000000000..88621e72c2 --- /dev/null +++ b/packages/client/src/components/instance-card-mini.vue @@ -0,0 +1,100 @@ +<template> +<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]"> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <div class="body"> + <span class="host">{{ instance.name ?? instance.host }}</span> + <span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span> + </div> + <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + instance: misskey.entities.Instance; +}>(); + +let chartValues = $ref<number[] | null>(null); + +os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { + // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く + res.requests.received.splice(0, 1); + chartValues = res.requests.received; +}); +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + padding: 16px; + background: var(--panel); + border-radius: 8px; + + > :global(.icon) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + object-fit: cover; + border-radius: 4px; + margin-right: 10px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.host) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 80%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > :global(.chart) { + height: 30px; + } + + &:global(.yellow) { + --c: rgb(255 196 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.red) { + --c: rgb(255 0 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.gray) { + --c: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } +} +</style> diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue index f386a8de9a..1a811c2d87 100644 --- a/packages/client/src/components/instance-stats.vue +++ b/packages/client/src/components/instance-stats.vue @@ -1,81 +1,219 @@ <template> <div class="zbcjwnqg"> - <div class="selects" style="display: flex;"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup :label="$ts.federation"> - <option value="federation">{{ $ts._charts.federation }}</option> - <option value="ap-request">{{ $ts._charts.apRequest }}</option> - </optgroup> - <optgroup :label="$ts.users"> - <option value="users">{{ $ts._charts.usersIncDec }}</option> - <option value="users-total">{{ $ts._charts.usersTotal }}</option> - <option value="active-users">{{ $ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="$ts.notes"> - <option value="notes">{{ $ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> - <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ $ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="$ts.drive"> - <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> - <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ $ts.perHour }}</option> - <option value="day">{{ $ts.perDay }}</option> - </MkSelect> + <div class="main"> + <div class="body"> + <div class="selects" style="display: flex;"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="$ts.federation"> + <option value="federation">{{ $ts._charts.federation }}</option> + <option value="ap-request">{{ $ts._charts.apRequest }}</option> + </optgroup> + <optgroup :label="$ts.users"> + <option value="users">{{ $ts._charts.usersIncDec }}</option> + <option value="users-total">{{ $ts._charts.usersTotal }}</option> + <option value="active-users">{{ $ts._charts.activeUsers }}</option> + </optgroup> + <optgroup :label="$ts.notes"> + <option value="notes">{{ $ts._charts.notesIncDec }}</option> + <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> + <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> + <option value="notes-total">{{ $ts._charts.notesTotal }}</option> + </optgroup> + <optgroup :label="$ts.drive"> + <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> + <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + </div> + </div> </div> - <div class="chart"> - <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + <div class="subpub"> + <div class="sub"> + <div class="title">Sub</div> + <canvas ref="subDoughnutEl"></canvas> + </div> + <div class="pub"> + <div class="title">Pub</div> + <canvas ref="pubDoughnutEl"></canvas> + </div> </div> </div> </template> -<script lang="ts"> -import { defineComponent, ref } from 'vue'; +<script lang="ts" setup> +import { onMounted } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; import MkSelect from '@/components/form/select.vue'; import MkChart from '@/components/chart.vue'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import * as os from '@/os'; -export default defineComponent({ - components: { - MkSelect, - MkChart, - }, +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); - props: { - chartLimit: { - type: Number, - required: false, - default: 90 +const props = withDefaults(defineProps<{ + chartLimit?: number; + detailed?: boolean; +}>(), { + chartLimit: 90, +}); + +const chartSpan = $ref<'hour' | 'day'>('hour'); +const chartSrc = $ref('active-users'); +let subDoughnutEl = $ref<HTMLCanvasElement>(); +let pubDoughnutEl = $ref<HTMLCanvasElement>(); + +const { handler: externalTooltipHandler1 } = useChartTooltip(); +const { handler: externalTooltipHandler2 } = useChartTooltip(); + +function createDoughnut(chartEl, tooltip, data) { + const chartInstance = new Chart(chartEl, { + type: 'doughnut', + data: { + labels: data.map(x => x.name), + datasets: [{ + backgroundColor: data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: data.map(x => x.value), + }], }, - detailed: { - type: Boolean, - required: false, - default: false + options: { + maintainAspectRatio: false, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && data[hit.index].onClick) { + data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: tooltip, + }, + }, }, - }, + }); + + return chartInstance; +} - setup() { - const chartSpan = ref<'hour' | 'day'>('hour'); - const chartSrc = ref('active-users'); +onMounted(() => { + os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { + createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); - return { - chartSrc, - chartSpan, - }; - }, + createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }])); + }); }); </script> <style lang="scss" scoped> .zbcjwnqg { - > .selects { + > .main { + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 16px; + + > .body { + > .chart { + padding: 8px 0 0 0; + } + } } - > .chart { - padding: 8px 0 0 0; + > .subpub { + display: flex; + gap: 16px; + + > .sub, > .pub { + flex: 1; + min-width: 0; + position: relative; + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + max-height: 300px; + + > .title { + position: absolute; + top: 24px; + left: 24px; + } + } + + @media (max-width: 600px) { + flex-direction: column; + } } } </style> diff --git a/packages/client/src/components/key-value.vue b/packages/client/src/components/key-value.vue index da98abd77c..3d665e159d 100644 --- a/packages/client/src/components/key-value.vue +++ b/packages/client/src/components/key-value.vue @@ -10,46 +10,27 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import * as os from '@/os'; -export default defineComponent({ - props: { - copy: { - type: String, - required: false, - default: null, - }, - oneline: { - type: Boolean, - required: false, - default: false, - }, - }, - - setup(props) { - const copy_ = () => { - copyToClipboard(props.copy); - os.success(); - }; - - return { - copy_ - }; - }, +const props = withDefaults(defineProps<{ + copy?: string | null; + oneline?: boolean; +}>(), { + copy: null, + oneline: false, }); + +const copy_ = () => { + copyToClipboard(props.copy); + os.success(); +}; </script> <style lang="scss" scoped> .alqyeyti { - > .key, > .value { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - > .key { font-size: 0.85em; padding: 0 0 0.25em 0; @@ -67,6 +48,9 @@ export default defineComponent({ > .value { width: 70%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } } diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue index ffefc1b085..a6025f8b27 100644 --- a/packages/client/src/components/launch-pad.vue +++ b/packages/client/src/components/launch-pad.vue @@ -16,13 +16,13 @@ </template> </div> <div class="sub"> - <a v-click-anime href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()"> + <button v-click-anime class="_button" @click="help"> <i class="fas fa-question-circle icon"></i> <div class="text">{{ $ts.help }}</div> - </a> + </button> <MkA v-click-anime to="/about" @click.passive="close()"> <i class="fas fa-info-circle icon"></i> - <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div> + <div class="text">{{ $ts.instanceInfo }}</div> </MkA> <MkA v-click-anime to="/about-misskey" @click.passive="close()"> <img src="/static-assets/favicon.png" class="icon"/> @@ -34,13 +34,14 @@ </template> <script lang="ts" setup> -import { } from 'vue'; +import { } from 'vue'; import MkModal from '@/components/ui/modal.vue'; import { menuDef } from '@/menu'; import { instanceName } from '@/config'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { deviceKind } from '@/scripts/device-kind'; +import * as os from '@/os'; const props = withDefaults(defineProps<{ src?: HTMLElement; @@ -73,6 +74,28 @@ const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuD function close() { modal.close(); } + +function help(ev: MouseEvent) { + os.popupMenu([{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'fas fa-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'fas fa-terminal', + }, null, { + text: i18n.ts.document, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], ev.currentTarget ?? ev.target); + + close(); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/marquee.vue b/packages/client/src/components/marquee.vue new file mode 100644 index 0000000000..ad9de9b845 --- /dev/null +++ b/packages/client/src/components/marquee.vue @@ -0,0 +1,106 @@ +<script lang="ts"> +import { h, onMounted, onUnmounted, ref, watch } from 'vue'; + +export default { + name: 'MarqueeText', + props: { + duration: { + type: Number, + default: 15, + }, + repeat: { + type: Number, + default: 2, + }, + paused: { + type: Boolean, + default: false, + }, + reverse: { + type: Boolean, + default: false, + }, + }, + setup(props) { + const contentEl = ref(); + + function calc() { + const eachLength = contentEl.value.offsetWidth / props.repeat; + const factor = 3000; + const duration = props.duration / ((1 / eachLength) * factor); + + contentEl.value.style.animationDuration = `${duration}s`; + } + + watch(() => props.duration, calc); + + onMounted(() => { + calc(); + }); + + onUnmounted(() => { + }); + + return { + contentEl, + }; + }, + render({ + $slots, $style, $props: { + duration, repeat, paused, reverse, + }, + }) { + return h('div', { class: [$style.wrap] }, [ + h('span', { + ref: 'contentEl', + class: [ + paused + ? $style.paused + : undefined, + $style.content, + ], + }, Array(repeat).fill( + h('span', { + class: $style.text, + style: { + animationDirection: reverse + ? 'reverse' + : undefined, + }, + }, $slots.default()), + )), + ]); + }, +}; +</script> + +<style lang="scss" module> +.wrap { + overflow: hidden; overflow: clip; + animation-play-state: running; + + &:hover { + animation-play-state: paused; + } +} +.content { + display: inline-block; + white-space: nowrap; + animation-play-state: inherit; +} +.text { + display: inline-block; + animation-name: marquee; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-duration: inherit; + animation-play-state: inherit; +} +.paused .text { + animation-play-state: paused; +} +@keyframes marquee { + 0% { transform:translateX(0); } + 100% { transform:translateX(-100%); } +} +</style> diff --git a/packages/client/src/components/media-image.vue b/packages/client/src/components/media-image.vue index 43639f6771..9d417bd99f 100644 --- a/packages/client/src/components/media-image.vue +++ b/packages/client/src/components/media-image.vue @@ -2,9 +2,9 @@ <div v-if="hide" class="qjewsnkg" @click="hide = false"> <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> <div class="text"> - <div> - <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> + <div class="wrapper"> + <b style="display: block;"><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> + <span style="display: block;">{{ $ts.clickToShow }}</span> </div> </div> </div> @@ -37,8 +37,8 @@ let hide = $ref(true); const url = (props.raw || defaultStore.state.loadRawImages) ? props.image.url : defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(props.image.thumbnailUrl) - : props.image.thumbnailUrl; + ? getStaticImageUrl(props.image.thumbnailUrl) + : props.image.thumbnailUrl; // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { @@ -68,15 +68,11 @@ watch(() => props.image, () => { justify-content: center; align-items: center; - > div { + > .wrapper { display: table-cell; text-align: center; font-size: 0.8em; color: #fff; - - > * { - display: block; - } } } } diff --git a/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue index 70c2f49afa..2c8bc0c04e 100644 --- a/packages/client/src/components/mention.vue +++ b/packages/client/src/components/mention.vue @@ -1,12 +1,12 @@ <template> -<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { isMe }]" :to="url" :style="{ background: bg }"> +<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { isMe }]" :to="url" :style="{ background: bgCss }"> <img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt=""> <span class="main"> <span class="username">@{{ username }}</span> <span v-if="(host != localHost) || $store.state.showFullAcct" :class="$style.mainHost">@{{ toUnicode(host) }}</span> </span> </MkA> -<a v-else :class="$style.root" :href="url" target="_blank" rel="noopener" :style="{ background: bg }"> +<a v-else :class="$style.root" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }"> <span class="main"> <span class="username">@{{ username }}</span> <span :class="$style.mainHost">@{{ toUnicode(host) }}</span> @@ -14,49 +14,31 @@ </a> </template> -<script lang="ts"> -import { defineComponent, useCssModule } from 'vue'; -import tinycolor from 'tinycolor2'; +<script lang="ts" setup> import { toUnicode } from 'punycode'; +import { useCssModule } from 'vue'; +import tinycolor from 'tinycolor2'; import { host as localHost } from '@/config'; import { $i } from '@/account'; -export default defineComponent({ - props: { - username: { - type: String, - required: true - }, - host: { - type: String, - required: true - } - }, - - setup(props) { - const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; +const props = defineProps<{ + username: string; + host: string; +}>(); - const url = `/${canonical}`; +const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; - const isMe = $i && ( - `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() - ); +const url = `/${canonical}`; - const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); - bg.setAlpha(0.1); +const isMe = $i && ( + `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() +); - useCssModule(); +const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); +bg.setAlpha(0.1); +const bgCss = bg.toRgbString(); - return { - localHost, - isMe, - url, - canonical, - toUnicode, - bg: bg.toRgbString(), - }; - }, -}); +useCssModule(); </script> <style lang="scss" module> diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts index 4556a82d55..f7dbca0d30 100644 --- a/packages/client/src/components/mfm.ts +++ b/packages/client/src/components/mfm.ts @@ -17,30 +17,30 @@ export default defineComponent({ props: { text: { type: String, - required: true + required: true, }, plain: { type: Boolean, - default: false + default: false, }, nowrap: { type: Boolean, - default: false + default: false, }, author: { type: Object, - default: null + default: null, }, i: { type: Object, - default: null + default: null, }, customEmojis: { required: false, }, isNote: { type: Boolean, - default: true + default: true, }, }, @@ -82,7 +82,7 @@ export default defineComponent({ case 'italic': { return h('i', { - style: 'font-style: oblique;' + style: 'font-style: oblique;', }, genEl(token.children)); } @@ -201,13 +201,13 @@ export default defineComponent({ case 'small': { return [h('small', { - style: 'opacity: 0.7;' + style: 'opacity: 0.7;', }, genEl(token.children))]; } case 'center': { return [h('div', { - style: 'text-align:center;' + style: 'text-align:center;', }, genEl(token.children))]; } @@ -231,7 +231,7 @@ export default defineComponent({ return [h(MkMention, { key: Math.random(), host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, - username: token.props.username + username: token.props.username, })]; } @@ -239,7 +239,7 @@ export default defineComponent({ return [h(MkA, { key: Math.random(), to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, - style: 'color:var(--hashtag);' + style: 'color:var(--hashtag);', }, `#${token.props.hashtag}`)]; } @@ -255,18 +255,18 @@ export default defineComponent({ return [h(MkCode, { key: Math.random(), code: token.props.code, - inline: true + inline: true, })]; } case 'quote': { if (!this.nowrap) { return [h('div', { - class: 'quote' + class: 'quote', }, genEl(token.children))]; } else { return [h('span', { - class: 'quote' + class: 'quote', }, genEl(token.children))]; } } @@ -276,7 +276,7 @@ export default defineComponent({ key: Math.random(), emoji: `:${token.props.name}:`, customEmojis: this.customEmojis, - normal: this.plain + normal: this.plain, })]; } @@ -285,7 +285,7 @@ export default defineComponent({ key: Math.random(), emoji: token.props.emoji, customEmojis: this.customEmojis, - normal: this.plain + normal: this.plain, })]; } @@ -293,7 +293,7 @@ export default defineComponent({ return [h(MkFormula, { key: Math.random(), formula: token.props.formula, - block: false + block: false, })]; } @@ -301,14 +301,14 @@ export default defineComponent({ return [h(MkFormula, { key: Math.random(), formula: token.props.formula, - block: true + block: true, })]; } case 'search': { return [h(MkGoogle, { key: Math.random(), - q: token.props.query + q: token.props.query, })]; } @@ -322,5 +322,5 @@ export default defineComponent({ // Parse ast to DOM return h('span', genEl(ast)); - } + }, }); diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue index 8c74eae876..c64ce163f9 100644 --- a/packages/client/src/components/mini-chart.vue +++ b/packages/client/src/components/mini-chart.vue @@ -2,89 +2,72 @@ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible"> <defs> <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> + <stop offset="0%" :stop-color="color" stop-opacity="0"></stop> + <stop offset="100%" :stop-color="color" stop-opacity="0.65"></stop> </linearGradient> - <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="polygonPoints" - fill="#fff" - fill-opacity="0.5"/> - <polyline - :points="polylinePoints" - fill="none" - stroke="#fff" - stroke-width="2"/> - <circle - :cx="headX" - :cy="headY" - r="3" - fill="#fff"/> - </mask> </defs> - <rect - x="-10" y="-10" - :width="viewBoxX + 20" :height="viewBoxY + 20" - :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/> + <polygon + :points="polygonPoints" + :style="`stroke: none; fill: url(#${ gradientId });`" + /> + <polyline + :points="polylinePoints" + fill="none" + :stroke="color" + stroke-width="2" + /> + <circle + :cx="headX" + :cy="headY" + r="3" + :fill="color" + /> </svg> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onUnmounted, watch } from 'vue'; import { v4 as uuid } from 'uuid'; -import * as os from '@/os'; +import tinycolor from 'tinycolor2'; +import { useInterval } from '@/scripts/use-interval'; -export default defineComponent({ - props: { - src: { - type: Array, - required: true - } - }, - data() { - return { - viewBoxX: 50, - viewBoxY: 30, - gradientId: uuid(), - maskId: uuid(), - polylinePoints: '', - polygonPoints: '', - headX: null, - headY: null, - clock: null - }; - }, - watch: { - src() { - this.draw(); - } - }, - created() { - this.draw(); +const props = defineProps<{ + src: number[]; +}>(); - // Vueが何故かWatchを発動させない場合があるので - this.clock = window.setInterval(this.draw, 1000); - }, - beforeUnmount() { - window.clearInterval(this.clock); - }, - methods: { - draw() { - const stats = this.src.slice().reverse(); - const peak = Math.max.apply(null, stats) || 1; +const viewBoxX = 50; +const viewBoxY = 50; +const gradientId = uuid(); +let polylinePoints = $ref(''); +let polygonPoints = $ref(''); +let headX = $ref<number | null>(null); +let headY = $ref<number | null>(null); +let clock = $ref<number | null>(null); +const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); +const color = accent.toRgbString(); - const polylinePoints = stats.map((n, i) => [ - i * (this.viewBoxX / (stats.length - 1)), - (1 - (n / peak)) * this.viewBoxY - ]); +function draw(): void { + const stats = props.src.slice().reverse(); + const peak = Math.max.apply(null, stats) || 1; - this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + const _polylinePoints = stats.map((n, i) => [ + i * (viewBoxX / (stats.length - 1)), + (1 - (n / peak)) * viewBoxY, + ]); - this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - this.headX = polylinePoints[polylinePoints.length - 1][0]; - this.headY = polylinePoints[polylinePoints.length - 1][1]; - } - } + polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; + + headX = _polylinePoints[_polylinePoints.length - 1][0]; + headY = _polylinePoints[_polylinePoints.length - 1][1]; +} + +watch(() => props.src, draw, { immediate: true }); + +// Vueが何故かWatchを発動させない場合があるので +useInterval(draw, 1000, { + immediate: false, + afterMounted: true, }); </script> diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue index 21bdb657b7..2fed0d35e8 100644 --- a/packages/client/src/components/modal-page-window.vue +++ b/packages/client/src/components/modal-page-window.vue @@ -1,163 +1,118 @@ <template> <MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> - <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> + <div ref="rootEl" class="hrmcaedk _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> <div class="header" @contextmenu="onContextmenu"> <button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button> <span v-else style="display: inline-block; width: 20px"></span> - <span v-if="pageInfo" class="title"> - <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i> - <span>{{ pageInfo.title }}</span> + <span v-if="pageMetadata?.value" class="title"> + <i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i> + <span>{{ pageMetadata?.value.title }}</span> </span> <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button> </div> <div class="body"> <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <keep-alive> - <component :is="component" v-bind="props" :ref="changePage"/> - </keep-alive> + <template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template> + <RouterView :router="router"/> </MkStickyContainer> </div> </div> </MkModal> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ComputedRef, provide } from 'vue'; import MkModal from '@/components/ui/modal.vue'; -import { popout } from '@/scripts/popout'; +import { popout as _popout } from '@/scripts/popout'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { resolve } from '@/router'; import { url } from '@/config'; -import * as symbols from '@/symbols'; import * as os from '@/os'; +import { mainRouter, routes } from '@/router'; +import { i18n } from '@/i18n'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { Router } from '@/nirax'; -export default defineComponent({ - components: { - MkModal, - }, +const props = defineProps<{ + initialPath: string; +}>(); - inject: { - sideViewHook: { - default: null, - }, - }, - - provide() { - return { - navHook: (path) => { - this.navigate(path); - }, - shouldHeaderThin: true, - }; - }, +defineEmits<{ + (ev: 'closed'): void; + (ev: 'click'): void; +}>(); - props: { - initialPath: { - type: String, - required: true, - }, - initialComponent: { - type: Object, - required: true, - }, - initialProps: { - type: Object, - required: false, - default: () => {}, - }, - }, +const router = new Router(routes, props.initialPath); - emits: ['closed'], +router.addListener('push', ctx => { + +}); - data() { - return { - width: 860, - height: 660, - pageInfo: null, - path: this.initialPath, - component: this.initialComponent, - props: this.initialProps, - history: [], - }; - }, +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let rootEl = $ref(); +let modal = $ref<InstanceType<typeof MkModal>>(); +let path = $ref(props.initialPath); +let width = $ref(860); +let height = $ref(660); +const history = []; - computed: { - url(): string { - return url + this.path; - }, +provide('router', router); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); +provide('shouldOmitHeaderTitle', true); +provide('shouldHeaderThin', true); - contextmenu() { - return [{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: this.expand, - }, this.sideViewHook ? { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.sideViewHook(this.path); - this.$refs.window.close(); - }, - } : undefined, { - icon: 'fas fa-external-link-alt', - text: this.$ts.popout, - action: this.popout, - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - }, - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - }, - }]; +const pageUrl = $computed(() => url + path); +const contextmenu = $computed(() => { + return [{ + type: 'label', + text: path, + }, { + icon: 'fas fa-expand-alt', + text: i18n.ts.showInPage, + action: expand, + }, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.popout, + action: popout, + }, null, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.openInNewTab, + action: () => { + window.open(pageUrl, '_blank'); + modal.close(); }, - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } + }, { + icon: 'fas fa-link', + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(pageUrl); }, + }]; +}); - navigate(path, record = true) { - if (record) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - }, +function navigate(path, record = true) { + if (record) history.push(router.getCurrentPath()); + router.push(path); +} - back() { - this.navigate(this.history.pop(), false); - }, +function back() { + navigate(history.pop(), false); +} - expand() { - this.$router.push(this.path); - this.$refs.window.close(); - }, +function expand() { + mainRouter.push(path); + modal.close(); +} - popout() { - popout(this.path, this.$el); - this.$refs.window.close(); - }, +function popout() { + _popout(path, rootEl); + modal.close(); +} - onContextmenu(ev: MouseEvent) { - os.contextMenu(this.contextmenu, ev); - }, - }, -}); +function onContextmenu(ev: MouseEvent) { + os.contextMenu(contextmenu, ev); +} </script> <style lang="scss" scoped> @@ -166,6 +121,7 @@ export default defineComponent({ display: flex; flex-direction: column; contain: content; + border-radius: var(--radius); --root-margin: 24px; @@ -184,7 +140,9 @@ export default defineComponent({ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - box-shadow: 0px 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); > button { height: $height; diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue index 6234b710d2..c05ab7fec4 100644 --- a/packages/client/src/components/note-detailed.vue +++ b/packages/client/src/components/note-detailed.vue @@ -26,12 +26,7 @@ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> <MkTime :time="note.createdAt"/> </button> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </div> <article class="article" @contextmenu.stop="onContextmenu"> @@ -43,12 +38,9 @@ <MkUserName :user="appearNote.user"/> </MkA> <span v-if="appearNote.user.isBot" class="is-bot">bot</span> - <span v-if="appearNote.visibility !== 'public'" class="visibility"> - <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="appearNote.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <div class="info"> + <MkVisibility :note="appearNote"/> + </div> </div> <div class="username"><MkAcct :user="appearNote.user"/></div> <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> @@ -134,6 +126,7 @@ import XPoll from './poll.vue'; import XRenoteButton from './renote-button.vue'; import MkUrlPreview from '@/components/url-preview.vue'; import MkInstanceTicker from '@/components/instance-ticker.vue'; +import MkVisibility from '@/components/visibility.vue'; import { pleaseLogin } from '@/scripts/please-login'; import { checkWordMute } from '@/scripts/check-word-mute'; import { userPage } from '@/filters/user'; @@ -251,12 +244,12 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton }), ev).then(focus); + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton }), menuButton.value, { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, { viaKeyboard, }).then(focus); } @@ -388,14 +381,6 @@ if (appearNote.replyId) { margin-right: 4px; } } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } @@ -441,6 +426,10 @@ if (appearNote.replyId) { border: solid 0.5px var(--divider); border-radius: 4px; } + + > .info { + float: right; + } } } } diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue index 56a3a37e75..0b05498566 100644 --- a/packages/client/src/components/note-header.vue +++ b/packages/client/src/components/note-header.vue @@ -9,12 +9,7 @@ <MkA class="created-at" :to="notePage(note)"> <MkTime :time="note.createdAt"/> </MkA> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </header> </template> @@ -22,6 +17,7 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; +import MkVisibility from '@/components/visibility.vue'; import { notePage } from '@/filters/note'; import { userPage } from '@/filters/user'; @@ -74,14 +70,6 @@ defineProps<{ flex-shrink: 0; margin-left: auto; font-size: 0.9em; - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } </style> diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue index a78b499654..be7214db19 100644 --- a/packages/client/src/components/note-preview.vue +++ b/packages/client/src/components/note-preview.vue @@ -27,7 +27,7 @@ const props = defineProps<{ display: flex; margin: 0; padding: 0; - overflow: clip; + overflow: hidden; overflow: clip; font-size: 0.95em; &.min-width_350px { diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue index b813b9a2b9..93c34b6bf4 100644 --- a/packages/client/src/components/note-simple.vue +++ b/packages/client/src/components/note-simple.vue @@ -36,7 +36,7 @@ const showContent = $ref(false); display: flex; margin: 0; padding: 0; - overflow: clip; + overflow: hidden; overflow: clip; font-size: 0.95em; &.min-width_350px { diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index e5744d1ce9..b494c70392 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -28,12 +28,7 @@ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> <MkTime :time="note.createdAt"/> </button> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </div> <article class="article" @contextmenu.stop="onContextmenu"> @@ -105,7 +100,7 @@ </template> <script lang="ts" setup> -import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue'; +import { computed, inject, onMounted, onUnmounted, reactive, ref, Ref } from 'vue'; import * as mfm from 'mfm-js'; import * as misskey from 'misskey-js'; import MkNoteSub from './MkNoteSub.vue'; @@ -118,6 +113,7 @@ import XPoll from './poll.vue'; import XRenoteButton from './renote-button.vue'; import MkUrlPreview from '@/components/url-preview.vue'; import MkInstanceTicker from '@/components/instance-ticker.vue'; +import MkVisibility from '@/components/visibility.vue'; import { pleaseLogin } from '@/scripts/please-login'; import { focusPrev, focusNext } from '@/scripts/focus'; import { checkWordMute } from '@/scripts/check-word-mute'; @@ -225,6 +221,8 @@ function undoReact(note): void { }); } +const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null); + function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; @@ -239,12 +237,12 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton }), ev).then(focus); + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton }), menuButton.value, { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { viaKeyboard, }).then(focus); } @@ -295,7 +293,7 @@ function readPromo() { position: relative; transition: box-shadow 0.1s ease; font-size: 1.05em; - overflow: clip; + overflow: hidden; overflow: clip; contain: content; // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 @@ -404,14 +402,6 @@ function readPromo() { margin-right: 4px; } } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue index 64d828394b..bf0a148f59 100644 --- a/packages/client/src/components/notification-setting-window.vue +++ b/packages/client/src/components/notification-setting-window.vue @@ -6,95 +6,82 @@ :with-ok-button="true" :ok-button-disabled="false" @ok="ok()" - @close="$refs.dialog.close()" - @closed="$emit('closed')" + @close="dialog.close()" + @closed="emit('closed')" > - <template #header>{{ $ts.notificationSetting }}</template> + <template #header>{{ i18n.ts.notificationSetting }}</template> <div class="_monolithic_"> <div v-if="showGlobalToggle" class="_section"> <MkSwitch v-model="useGlobalSetting"> - {{ $ts.useGlobalSetting }} - <template #caption>{{ $ts.useGlobalSettingDesc }}</template> + {{ i18n.ts.useGlobalSetting }} + <template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template> </MkSwitch> </div> <div v-if="!useGlobalSetting" class="_section"> - <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo> - <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> - <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> - <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch> + <MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo> + <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> + <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> </div> </div> </XModalWindow> </template> -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import { notificationTypes } from 'misskey-js'; import MkSwitch from './form/switch.vue'; import MkInfo from './ui/info.vue'; import MkButton from './ui/button.vue'; import XModalWindow from '@/components/ui/modal-window.vue'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XModalWindow, - MkSwitch, - MkInfo, - MkButton, - }, +const emit = defineEmits<{ + (ev: 'done', v: { includingTypes: string[] | null }): void, + (ev: 'closed'): void, +}>(); - props: { - includingTypes: { - // TODO: これで型に合わないものを弾いてくれるのかどうか要調査 - type: Array as PropType<typeof notificationTypes[number][]>, - required: false, - default: null, - }, - showGlobalToggle: { - type: Boolean, - required: false, - default: true, - }, - }, +const props = withDefaults(defineProps<{ + includingTypes?: typeof notificationTypes[number][] | null; + showGlobalToggle?: boolean; +}>(), { + includingTypes: () => [], + showGlobalToggle: true, +}); - emits: ['done', 'closed'], +let includingTypes = $computed(() => props.includingTypes || []); - data() { - return { - typesMap: {} as Record<typeof notificationTypes[number], boolean>, - useGlobalSetting: false, - notificationTypes, - }; - }, +const dialog = $ref<InstanceType<typeof XModalWindow>>(); - created() { - this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle; +let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({}); +let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle); - for (const type of this.notificationTypes) { - this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type); - } - }, +for (const ntype of notificationTypes) { + typesMap[ntype] = includingTypes.includes(ntype); +} - methods: { - ok() { - const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][]) - .filter(type => this.typesMap[type]); +function ok() { + if (useGlobalSetting) { + emit('done', { includingTypes: null }); + } else { + emit('done', { + includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) + .filter(type => typesMap[type]), + }); + } - this.$emit('done', { includingTypes }); - this.$refs.dialog.close(); - }, + dialog.close(); +} - disableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = false; - } - }, +function disableAll() { + for (const type in typesMap) { + typesMap[type as typeof notificationTypes[number]] = false; + } +} - enableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = true; - } - }, - }, -}); +function enableAll() { + for (const type in typesMap) { + typesMap[type as typeof notificationTypes[number]] = true; + } +} </script> diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue index cbfd809f37..32f9fd07d8 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/notification.vue @@ -72,8 +72,8 @@ </div> </template> -<script lang="ts"> -import { defineComponent, ref, onMounted, onUnmounted, watch } from 'vue'; +<script lang="ts" setup> +import { ref, onMounted, onUnmounted, watch } from 'vue'; import * as misskey from 'misskey-js'; import XReactionIcon from './reaction-icon.vue'; import MkFollowButton from './follow-button.vue'; @@ -86,105 +86,77 @@ import * as os from '@/os'; import { stream } from '@/stream'; import { useTooltip } from '@/scripts/use-tooltip'; -export default defineComponent({ - components: { - XReactionIcon, MkFollowButton, - }, - - props: { - notification: { - type: Object, - required: true, - }, - withTime: { - type: Boolean, - required: false, - default: false, - }, - full: { - type: Boolean, - required: false, - default: false, - }, - }, +const props = withDefaults(defineProps<{ + notification: misskey.entities.Notification; + withTime?: boolean; + full?: boolean; +}>(), { + withTime: false, + full: false, +}); - setup(props) { - const elRef = ref<HTMLElement>(null); - const reactionRef = ref(null); +const elRef = ref<HTMLElement>(null); +const reactionRef = ref(null); - onMounted(() => { - if (!props.notification.isRead) { - const readObserver = new IntersectionObserver((entries, observer) => { - if (!entries.some(entry => entry.isIntersecting)) return; - stream.send('readNotification', { - id: props.notification.id, - }); - observer.disconnect(); - }); +let readObserver: IntersectionObserver | undefined; +let connection; - readObserver.observe(elRef.value); +onMounted(() => { + if (!props.notification.isRead) { + readObserver = new IntersectionObserver((entries, observer) => { + if (!entries.some(entry => entry.isIntersecting)) return; + stream.send('readNotification', { + id: props.notification.id, + }); + observer.disconnect(); + }); - const connection = stream.useChannel('main'); - connection.on('readAllNotifications', () => readObserver.disconnect()); + readObserver.observe(elRef.value); - watch(props.notification.isRead, () => { - readObserver.disconnect(); - }); + connection = stream.useChannel('main'); + connection.on('readAllNotifications', () => readObserver.disconnect()); - onUnmounted(() => { - readObserver.disconnect(); - connection.dispose(); - }); - } + watch(props.notification.isRead, () => { + readObserver.disconnect(); }); + } +}); - const followRequestDone = ref(false); - const groupInviteDone = ref(false); +onUnmounted(() => { + if (readObserver) readObserver.disconnect(); + if (connection) connection.dispose(); +}); - const acceptFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/accept', { userId: props.notification.user.id }); - }; +const followRequestDone = ref(false); +const groupInviteDone = ref(false); - const rejectFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/reject', { userId: props.notification.user.id }); - }; +const acceptFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/accept', { userId: props.notification.user.id }); +}; - const acceptGroupInvitation = () => { - groupInviteDone.value = true; - os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); - }; +const rejectFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/reject', { userId: props.notification.user.id }); +}; - const rejectGroupInvitation = () => { - groupInviteDone.value = true; - os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); - }; +const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); +}; - useTooltip(reactionRef, (showing) => { - os.popup(XReactionTooltip, { - showing, - reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, - emojis: props.notification.note.emojis, - targetElement: reactionRef.value.$el, - }, {}, 'closed'); - }); +const rejectGroupInvitation = () => { + groupInviteDone.value = true; + os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); +}; - return { - getNoteSummary: (note: misskey.entities.Note) => getNoteSummary(note), - followRequestDone, - groupInviteDone, - notePage, - userPage, - acceptFollowRequest, - rejectFollowRequest, - acceptGroupInvitation, - rejectGroupInvitation, - elRef, - reactionRef, - i18n, - }; - }, +useTooltip(reactionRef, (showing) => { + os.popup(XReactionTooltip, { + showing, + reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, + emojis: props.notification.note.emojis, + targetElement: reactionRef.value.$el, + }, {}, 'closed'); }); </script> diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue index 8eb569c369..eb19ad488c 100644 --- a/packages/client/src/components/notifications.vue +++ b/packages/client/src/components/notifications.vue @@ -60,8 +60,10 @@ const onNotification = (notification) => { } }; +let connection; + onMounted(() => { - const connection = stream.useChannel('main'); + connection = stream.useChannel('main'); connection.on('notification', onNotification); connection.on('readAllNotifications', () => { if (pagingComponent.value) { @@ -87,10 +89,10 @@ onMounted(() => { } } }); +}); - onUnmounted(() => { - connection.dispose(); - }); +onUnmounted(() => { + if (connection) connection.dispose(); }); </script> diff --git a/packages/client/src/components/object-view.value.vue b/packages/client/src/components/object-view.value.vue index 6f388636dd..0c7230d783 100644 --- a/packages/client/src/components/object-view.value.vue +++ b/packages/client/src/components/object-view.value.vue @@ -1,31 +1,35 @@ <template> <div class="igpposuu _monospace"> <div v-if="value === null" class="null">null</div> - <div v-else-if="typeof value === 'boolean'" class="boolean">{{ value ? 'true' : 'false' }}</div> + <div v-else-if="typeof value === 'boolean'" class="boolean" :class="{ true: value, false: !value }">{{ value ? 'true' : 'false' }}</div> <div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div> <div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div> - <div v-else-if="Array.isArray(value)" class="array"> - <button @click="collapsed_ = !collapsed_">[ {{ collapsed_ ? '+' : '-' }} ]</button> - <template v-if="!collapsed_"> - <div v-for="i in value.length" class="element"> - {{ i }}: <XValue :value="value[i - 1]" collapsed/> - </div> - </template> + <div v-else-if="isArray(value) && isEmpty(value)" class="array empty">[]</div> + <div v-else-if="isArray(value)" class="array"> + <div v-for="i in value.length" class="element"> + {{ i }}: <XValue :value="value[i - 1]" collapsed/> + </div> </div> - <div v-else-if="typeof value === 'object'" class="object"> - <button @click="collapsed_ = !collapsed_">{ {{ collapsed_ ? '+' : '-' }} }</button> - <template v-if="!collapsed_"> - <div v-for="k in Object.keys(value)" class="kv"> - <div class="k">{{ k }}:</div> - <div class="v"><XValue :value="value[k]" collapsed/></div> + <div v-else-if="isObject(value) && isEmpty(value)" class="object empty">{}</div> + <div v-else-if="isObject(value)" class="object"> + <div v-for="k in Object.keys(value)" class="kv"> + <button class="toggle _button" :class="{ visible: collapsable(value[k]) }" @click="collapsed[k] = !collapsed[k]">{{ collapsed[k] ? '+' : '-' }}</button> + <div class="k">{{ k }}:</div> + <div v-if="collapsed[k]" class="v"> + <button class="_button" @click="collapsed[k] = !collapsed[k]"> + <template v-if="typeof value[k] === 'string'">"..."</template> + <template v-else-if="isArray(value[k])">[...]</template> + <template v-else-if="isObject(value[k])">{...}</template> + </button> </div> - </template> + <div v-else class="v"><XValue :value="value[k]"/></div> + </div> </div> </div> </template> <script lang="ts"> -import { computed, defineComponent, ref } from 'vue'; +import { computed, defineComponent, reactive, ref } from 'vue'; import number from '@/filters/number'; export default defineComponent({ @@ -33,24 +37,44 @@ export default defineComponent({ props: { value: { - type: Object, required: true, }, - collapsed: { - type: Boolean, - required: false, - default: false, - }, }, setup(props) { - const collapsed_ = ref(props.collapsed); + const collapsed = reactive({}); + + if (isObject(props.value)) { + for (const key in props.value) { + collapsed[key] = collapsable(props.value[key]); + } + } + + function isObject(v): boolean { + return typeof v === 'object' && !Array.isArray(v) && v !== null; + } + + function isArray(v): boolean { + return Array.isArray(v); + } + + function isEmpty(v): boolean { + return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); + } + + function collapsable(v): boolean { + return (isObject(v) || isArray(v)) && !isEmpty(v); + } return { number, - collapsed_, + collapsed, + isObject, + isArray, + isEmpty, + collapsable, }; - } + }, }); </script> @@ -66,6 +90,14 @@ export default defineComponent({ > .boolean { display: inline; color: var(--codeBoolean); + + &.true { + font-weight: bold; + } + + &.false { + opacity: 0.7; + } } > .string { @@ -78,7 +110,12 @@ export default defineComponent({ color: var(--codeNumber); } - > .array { + > .array.empty { + display: inline; + opacity: 0.7; + } + + > .array:not(.empty) { display: inline; > .element { @@ -87,13 +124,28 @@ export default defineComponent({ } } - > .object { + > .object.empty { + display: inline; + opacity: 0.7; + } + + > .object:not(.empty) { display: inline; > .kv { display: block; padding-left: 16px; + > .toggle { + width: 16px; + color: var(--accent); + visibility: hidden; + + &.visible { + visibility: visible; + } + } + > .k { display: inline; margin-right: 8px; diff --git a/packages/client/src/components/object-view.vue b/packages/client/src/components/object-view.vue index e9db96de8c..db66049fce 100644 --- a/packages/client/src/components/object-view.vue +++ b/packages/client/src/components/object-view.vue @@ -4,26 +4,13 @@ </div> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import XValue from './object-view.value.vue'; -export default defineComponent({ - components: { - XValue - }, - - props: { - value: { - type: Object, - required: true, - }, - }, - - setup(props) { - - } -}); +const props = defineProps<{ + value: Record<string, unknown>; +}>(); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/page-preview.vue b/packages/client/src/components/page-preview.vue index 090aff6c65..009582e540 100644 --- a/packages/client/src/components/page-preview.vue +++ b/packages/client/src/components/page-preview.vue @@ -1,5 +1,5 @@ <template> -<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1"> +<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block" tabindex="-1"> <div v-if="page.eyeCatchingImage" class="thumbnail" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> <article> <header> @@ -23,12 +23,12 @@ export default defineComponent({ props: { page: { type: Object, - required: true + required: true, }, }, methods: { - userName - } + userName, + }, }); </script> diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue index 7455236bad..5b06c7718c 100644 --- a/packages/client/src/components/page-window.vue +++ b/packages/client/src/components/page-window.vue @@ -1,191 +1,144 @@ <template> -<XWindow ref="window" +<XWindow + ref="windowEl" :initial-width="500" :initial-height="500" :can-resize="true" :close-button="true" + :buttons-left="buttonsLeft" + :buttons-right="buttonsRight" :contextmenu="contextmenu" @closed="$emit('closed')" > <template #header> - <template v-if="pageInfo"> - <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i> - <span>{{ pageInfo.title }}</span> + <template v-if="pageMetadata?.value"> + <i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageMetadata.value.title }}</span> </template> </template> - <template #headerLeft> - <button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button> - </template> - <template #headerRight> - <button v-tooltip="$ts.showInPage" class="_button" @click="expand()"><i class="fas fa-expand-alt"></i></button> - <button v-tooltip="$ts.popout" class="_button" @click="popout()"><i class="fas fa-external-link-alt"></i></button> - <button class="_button" @click="menu"><i class="fas fa-ellipsis-h"></i></button> - </template> - <div class="yrolvcoq" :style="{ background: pageInfo?.bg }"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <component :is="component" v-bind="props" :ref="changePage"/> - </MkStickyContainer> + <div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }"> + <RouterView :router="router"/> </div> </XWindow> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ComputedRef, inject, provide } from 'vue'; +import RouterView from './global/router-view.vue'; import XWindow from '@/components/ui/window.vue'; -import { popout } from '@/scripts/popout'; +import { popout as _popout } from '@/scripts/popout'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { resolve } from '@/router'; import { url } from '@/config'; -import * as symbols from '@/symbols'; import * as os from '@/os'; +import { mainRouter, routes } from '@/router'; +import { Router } from '@/nirax'; +import { i18n } from '@/i18n'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - XWindow, - }, +const props = defineProps<{ + initialPath: string; +}>(); - inject: { - sideViewHook: { - default: null - } - }, +defineEmits<{ + (ev: 'closed'): void; +}>(); - provide() { - return { - navHook: (path) => { - this.navigate(path); - }, - shouldHeaderThin: true, - }; - }, +const router = new Router(routes, props.initialPath); - props: { - initialPath: { - type: String, - required: true, - }, - initialComponent: { - type: Object, - required: true, - }, - initialProps: { - type: Object, - required: false, - default: () => {}, - }, - }, +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let windowEl = $ref<InstanceType<typeof XWindow>>(); +const history = $ref<{ path: string; key: any; }[]>([{ + path: router.getCurrentPath(), + key: router.getCurrentKey(), +}]); +const buttonsLeft = $computed(() => { + const buttons = []; - emits: ['closed'], + if (history.length > 1) { + buttons.push({ + icon: 'fas fa-arrow-left', + onClick: back, + }); + } - data() { - return { - pageInfo: null, - path: this.initialPath, - component: this.initialComponent, - props: this.initialProps, - history: [], - }; - }, + return buttons; +}); +const buttonsRight = $computed(() => { + const buttons = [{ + icon: 'fas fa-expand-alt', + title: i18n.ts.showInPage, + onClick: expand, + }]; - computed: { - url(): string { - return url + this.path; - }, + return buttons; +}); - contextmenu() { - return [{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: this.expand - }, this.sideViewHook ? { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.sideViewHook(this.path); - this.$refs.window.close(); - } - } : undefined, { - icon: 'fas fa-external-link-alt', - text: this.$ts.popout, - action: this.popout - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }]; - }, - }, +router.addListener('push', ctx => { + history.push({ path: ctx.path, key: ctx.key }); +}); - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, +provide('router', router); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); +provide('shouldOmitHeaderTitle', true); +provide('shouldHeaderThin', true); - navigate(path, record = true) { - if (record) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - }, +const contextmenu = $computed(() => ([{ + icon: 'fas fa-expand-alt', + text: i18n.ts.showInPage, + action: expand, +}, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.popout, + action: popout, +}, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.openInNewTab, + action: () => { + window.open(url + router.getCurrentPath(), '_blank'); + windowEl.close(); + }, +}, { + icon: 'fas fa-link', + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(url + router.getCurrentPath()); + }, +}])); + +function menu(ev) { + os.popupMenu(contextmenu, ev.currentTarget ?? ev.target); +} - menu(ev) { - os.popupMenu([{ - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }], ev.currentTarget ?? ev.target); - }, +function back() { + history.pop(); + router.change(history[history.length - 1].path, history[history.length - 1].key); +} - back() { - this.navigate(this.history.pop(), false); - }, +function close() { + windowEl.close(); +} - close() { - this.$refs.window.close(); - }, +function expand() { + mainRouter.push(router.getCurrentPath()); + windowEl.close(); +} - expand() { - this.$router.push(this.path); - this.$refs.window.close(); - }, +function popout() { + _popout(router.getCurrentPath(), windowEl.$el); + windowEl.close(); +} - popout() { - popout(this.path, this.$el); - this.$refs.window.close(); - }, - }, +defineExpose({ + close, }); </script> <style lang="scss" scoped> .yrolvcoq { min-height: 100%; + background: var(--bg); } </style> diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue index a067762372..58c43b22bc 100644 --- a/packages/client/src/components/page/page.vue +++ b/packages/client/src/components/page/page.vue @@ -24,7 +24,6 @@ export default defineComponent({ }, }, setup(props, ctx) { - const hpml = new Hpml(props.page, { randomSeed: Math.random(), visitor: $i, diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/poll-editor.vue index 9aa5510c7f..a068aca79e 100644 --- a/packages/client/src/components/poll-editor.vue +++ b/packages/client/src/components/poll-editor.vue @@ -5,7 +5,7 @@ </p> <ul> <li v-for="(choice, i) in choices" :key="i"> - <MkInput class="input" :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> + <MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> </MkInput> <button class="_button" @click="remove(i)"> <i class="fas fa-times"></i> @@ -17,25 +17,25 @@ <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch> <section> <div> - <MkSelect v-model="expiration"> + <MkSelect v-model="expiration" small> <template #label>{{ $ts._poll.expiration }}</template> <option value="infinite">{{ $ts._poll.infinite }}</option> <option value="at">{{ $ts._poll.at }}</option> <option value="after">{{ $ts._poll.after }}</option> </MkSelect> <section v-if="expiration === 'at'"> - <MkInput v-model="atDate" type="date" class="input"> + <MkInput v-model="atDate" small type="date" class="input"> <template #label>{{ $ts._poll.deadlineDate }}</template> </MkInput> - <MkInput v-model="atTime" type="time" class="input"> + <MkInput v-model="atTime" small type="time" class="input"> <template #label>{{ $ts._poll.deadlineTime }}</template> </MkInput> </section> <section v-else-if="expiration === 'after'"> - <MkInput v-model="after" type="number" class="input"> + <MkInput v-model="after" small type="number" class="input"> <template #label>{{ $ts._poll.duration }}</template> </MkInput> - <MkSelect v-model="unit"> + <MkSelect v-model="unit" small> <option value="second">{{ $ts._time.second }}</option> <option value="minute">{{ $ts._time.minute }}</option> <option value="hour">{{ $ts._time.hour }}</option> @@ -49,12 +49,12 @@ <script lang="ts" setup> import { ref, watch } from 'vue'; -import { addTime } from '@/scripts/time'; -import { formatDateTimeString } from '@/scripts/format-time-string'; import MkInput from './form/input.vue'; import MkSelect from './form/select.vue'; import MkSwitch from './form/switch.vue'; import MkButton from './ui/button.vue'; +import { formatDateTimeString } from '@/scripts/format-time-string'; +import { addTime } from '@/scripts/time'; const props = defineProps<{ modelValue: { @@ -116,8 +116,11 @@ function get() { let base = parseInt(after.value); switch (unit.value) { case 'day': base *= 24; + // fallthrough case 'hour': base *= 60; + // fallthrough case 'minute': base *= 60; + // fallthrough case 'second': return base *= 1000; default: return null; } @@ -129,7 +132,7 @@ function get() { ...( expiration.value === 'at' ? { expiresAt: calcAt() } : expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} - ) + ), }; } diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue index 171b4a4770..35f87325d8 100644 --- a/packages/client/src/components/poll.vue +++ b/packages/client/src/components/poll.vue @@ -24,20 +24,22 @@ <script lang="ts"> import { computed, defineComponent, onUnmounted, ref, toRef } from 'vue'; import { sum } from '@/scripts/array'; +import { pleaseLogin } from '@/scripts/please-login'; import * as os from '@/os'; import { i18n } from '@/i18n'; +import { useInterval } from '@/scripts/use-interval'; export default defineComponent({ props: { note: { type: Object, - required: true + required: true, }, readOnly: { type: Boolean, required: false, default: false, - } + }, }, setup(props) { @@ -53,7 +55,7 @@ export default defineComponent({ s: Math.floor(remaining.value % 60), m: Math.floor(remaining.value / 60) % 60, h: Math.floor(remaining.value / 3600) % 24, - d: Math.floor(remaining.value / 86400) + d: Math.floor(remaining.value / 86400), })); const showResult = ref(props.readOnly || isVoted.value); @@ -67,14 +69,15 @@ export default defineComponent({ } }; - tick(); - const intevalId = window.setInterval(tick, 3000); - onUnmounted(() => { - window.clearInterval(intevalId); + useInterval(tick, 3000, { + immediate: true, + afterMounted: false, }); } const vote = async (id) => { + pleaseLogin(); + if (props.readOnly || closed.value || isVoted.value) return; const { canceled } = await os.confirm({ diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue index 6b9827407b..98bf2df09a 100644 --- a/packages/client/src/components/post-form-attaches.vue +++ b/packages/client/src/components/post-form-attaches.vue @@ -2,7 +2,7 @@ <div v-show="files.length != 0" class="skeikyzd"> <XDraggable v-model="_files" class="files" item-key="id" animation="150" delay="100" delay-on-touch-only="true"> <template #item="{element}"> - <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> + <div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> <div v-if="element.isSensitive" class="sensitive"> <i class="fas fa-exclamation-triangle icon"></i> @@ -22,18 +22,18 @@ import * as os from '@/os'; export default defineComponent({ components: { XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - MkDriveFileThumbnail + MkDriveFileThumbnail, }, props: { files: { type: Array, - required: true + required: true, }, detachMediaFn: { type: Function, - required: false - } + required: false, + }, }, emits: ['updated', 'detach', 'changeSensitive', 'changeName'], @@ -51,8 +51,8 @@ export default defineComponent({ }, set(value) { this.$emit('updated', value); - } - } + }, + }, }, methods: { @@ -66,7 +66,7 @@ export default defineComponent({ toggleSensitive(file) { os.api('drive/files/update', { fileId: file.id, - isSensitive: !file.isSensitive + isSensitive: !file.isSensitive, }).then(() => { this.$emit('changeSensitive', file, !file.isSensitive); }); @@ -75,12 +75,12 @@ export default defineComponent({ const { canceled, result } = await os.inputText({ title: this.$ts.enterFileName, default: file.name, - allowEmpty: false + allowEmpty: false, }); if (canceled) return; os.api('drive/files/update', { fileId: file.id, - name: result + name: result, }).then(() => { this.$emit('changeName', file, result); file.name = result; @@ -88,13 +88,13 @@ export default defineComponent({ }, async describe(file) { - os.popup(defineAsyncComponent(() => import("@/components/media-caption.vue")), { + os.popup(defineAsyncComponent(() => import('@/components/media-caption.vue')), { title: this.$ts.describeFile, input: { placeholder: this.$ts.inputNewDescription, - default: file.comment !== null ? file.comment : "", + default: file.comment !== null ? file.comment : '', }, - image: file + image: file, }, { done: result => { if (!result || result.canceled) return; @@ -105,7 +105,7 @@ export default defineComponent({ }).then(() => { file.comment = comment; }); - } + }, }, 'closed'); }, @@ -114,22 +114,22 @@ export default defineComponent({ this.menu = os.popupMenu([{ text: this.$ts.renameFile, icon: 'fas fa-i-cursor', - action: () => { this.rename(file); } + action: () => { this.rename(file); }, }, { text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', - action: () => { this.toggleSensitive(file); } + action: () => { this.toggleSensitive(file); }, }, { text: this.$ts.describeFile, icon: 'fas fa-i-cursor', - action: () => { this.describe(file); } + action: () => { this.describe(file); }, }, { text: this.$ts.attachCancel, icon: 'fas fa-times-circle', - action: () => { this.detachMedia(file.id); } + action: () => { this.detachMedia(file.id); }, }], ev.currentTarget ?? ev.target).then(() => this.menu = null); - } - } + }, + }, }); </script> @@ -142,7 +142,7 @@ export default defineComponent({ display: flex; flex-wrap: wrap; - > div { + > .file { position: relative; width: 64px; height: 64px; diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue index 0197313e0e..77fcd79c13 100644 --- a/packages/client/src/components/post-form.vue +++ b/packages/client/src/components/post-form.vue @@ -1,5 +1,6 @@ <template> -<div v-size="{ max: [310, 500] }" class="gafaadew" +<div + v-size="{ max: [310, 500] }" class="gafaadew" :class="{ modal, _popup: modal }" @dragover.stop="onDragover" @dragenter="onDragenter" @@ -11,7 +12,7 @@ <button v-click-anime v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu"> <MkAvatar :user="postAccount ?? $i" class="avatar"/> </button> - <div> + <div class="right"> <span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span> <span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span> <button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility"> @@ -68,6 +69,8 @@ import * as misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { length } from 'stringz'; import { toASCII } from 'punycode/'; +import * as Acct from 'misskey-js/built/acct'; +import { throttle } from 'throttle-debounce'; import XNoteSimple from './note-simple.vue'; import XNotePreview from './note-preview.vue'; import XPostFormAttaches from './post-form-attaches.vue'; @@ -75,14 +78,12 @@ import XPollEditor from './poll-editor.vue'; import { host, url } from '@/config'; import { erase, unique } from '@/scripts/array'; import { extractMentions } from '@/scripts/extract-mentions'; -import * as Acct from 'misskey-js/built/acct'; import { formatTimeString } from '@/scripts/format-time-string'; import { Autocomplete } from '@/scripts/autocomplete'; import * as os from '@/os'; import { stream } from '@/stream'; import { selectFiles } from '@/scripts/select-file'; import { defaultStore, notePostInterruptors, postFormActions } from '@/store'; -import { throttle } from 'throttle-debounce'; import MkInfo from '@/components/ui/info.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; @@ -181,7 +182,7 @@ const placeholder = $computed((): string => { i18n.ts._postForm._placeholders.c, i18n.ts._postForm._placeholders.d, i18n.ts._postForm._placeholders.e, - i18n.ts._postForm._placeholders.f + i18n.ts._postForm._placeholders.f, ]; return xs[Math.floor(Math.random() * xs.length)]; } @@ -238,10 +239,10 @@ if (props.reply && props.reply.text != null) { for (const x of extractMentions(ast)) { const mention = x.host ? - `@${x.username}@${toASCII(x.host)}` : - (otherHost == null || otherHost === host) ? - `@${x.username}` : - `@${x.username}@${toASCII(otherHost)}`; + `@${x.username}@${toASCII(x.host)}` : + (otherHost == null || otherHost === host) ? + `@${x.username}` : + `@${x.username}@${toASCII(otherHost)}`; // 自分は除外 if ($i.username === x.username && (x.host == null || x.host === host)) continue; @@ -263,7 +264,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib visibility = props.reply.visibility; if (props.reply.visibility === 'specified') { os.api('users/show', { - userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId) + userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), }).then(users => { users.forEach(pushVisibleUser); }); @@ -399,7 +400,7 @@ function setVisibility() { if (defaultStore.state.rememberNoteVisibility) { defaultStore.set('localOnly', localOnly); } - } + }, }, 'closed'); } @@ -522,8 +523,8 @@ function saveDraft() { visibility: visibility, localOnly: localOnly, files: files, - poll: poll - } + poll: poll, + }, }; localStorage.setItem('drafts', JSON.stringify(draftData)); @@ -612,11 +613,11 @@ function showActions(ev) { text: action.title, action: () => { action.handler({ - text: text + text: text, }, (key, value) => { if (key === 'text') { text = value; } }); - } + }, })), ev.currentTarget ?? ev.target); } @@ -726,7 +727,7 @@ onMounted(() => { } } - > div { + > .right { position: absolute; top: 0; right: 0; @@ -924,7 +925,7 @@ onMounted(() => { line-height: 50px; } - > div { + > .right { > .text-count { line-height: 50px; } diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue deleted file mode 100644 index 7bb548cf06..0000000000 --- a/packages/client/src/components/queue-chart.vue +++ /dev/null @@ -1,232 +0,0 @@ -<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 '@/filters/number'; -import * as os from '@/os'; -import { defaultStore } from '@/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, - borderJoinStyle: 'round', - borderColor: '#00E396', - backgroundColor: alpha('#00E396', 0.1), - data: [] - }, { - label: 'Active', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#00BCD4', - backgroundColor: alpha('#00BCD4', 0.1), - data: [] - }, { - label: 'Waiting', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#FFB300', - backgroundColor: alpha('#FFB300', 0.1), - yAxisID: 'y2', - data: [] - }, { - label: 'Delayed', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#E53935', - borderDash: [5, 5], - fill: false, - yAxisID: 'y2', - data: [] - }], - }, - options: { - aspectRatio: 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 8, - }, - }, - scales: { - x: { - grid: { - display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - ticks: { - display: false, - maxTicksLimit: 10 - }, - }, - y: { - min: 0, - stack: 'queue', - stackWeight: 2, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - }, - y2: { - min: 0, - offset: true, - stack: 'queue', - stackWeight: 1, - 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/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue index 91a90a6996..c29bd46400 100644 --- a/packages/client/src/components/reactions-viewer.reaction.vue +++ b/packages/client/src/components/reactions-viewer.reaction.vue @@ -12,106 +12,82 @@ </button> </template> -<script lang="ts"> -import { computed, defineComponent, onMounted, ref, watch } from 'vue'; +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import * as misskey from 'misskey-js'; import XDetails from '@/components/reactions-viewer.details.vue'; import XReactionIcon from '@/components/reaction-icon.vue'; import * as os from '@/os'; import { useTooltip } from '@/scripts/use-tooltip'; import { $i } from '@/account'; -export default defineComponent({ - components: { - XReactionIcon - }, +const props = defineProps<{ + reaction: string; + count: number; + isInitial: boolean; + note: misskey.entities.Note; +}>(); - props: { - reaction: { - type: String, - required: true, - }, - count: { - type: Number, - required: true, - }, - isInitial: { - type: Boolean, - required: true, - }, - note: { - type: Object, - required: true, - }, - }, +const buttonRef = ref<HTMLElement>(); - setup(props) { - const buttonRef = ref<HTMLElement>(); +const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); - const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); +const toggleReaction = () => { + if (!canToggle.value) return; - const toggleReaction = () => { - if (!canToggle.value) return; - - const oldReaction = props.note.myReaction; - if (oldReaction) { - os.api('notes/reactions/delete', { - noteId: props.note.id - }).then(() => { - if (oldReaction !== props.reaction) { - os.api('notes/reactions/create', { - noteId: props.note.id, - reaction: props.reaction - }); - } - }); - } else { + const oldReaction = props.note.myReaction; + if (oldReaction) { + os.api('notes/reactions/delete', { + noteId: props.note.id, + }).then(() => { + if (oldReaction !== props.reaction) { os.api('notes/reactions/create', { noteId: props.note.id, - reaction: props.reaction + reaction: props.reaction, }); } - }; - - const anime = () => { - if (document.hidden) return; - - // TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション - }; - - watch(() => props.count, (newCount, oldCount) => { - if (oldCount < newCount) anime(); }); - - onMounted(() => { - if (!props.isInitial) anime(); + } else { + os.api('notes/reactions/create', { + noteId: props.note.id, + reaction: props.reaction, }); + } +}; - useTooltip(buttonRef, async (showing) => { - const reactions = await os.api('notes/reactions', { - noteId: props.note.id, - type: props.reaction, - limit: 11 - }); +const anime = () => { + if (document.hidden) return; - const users = reactions.map(x => x.user); + // TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション +}; - os.popup(XDetails, { - showing, - reaction: props.reaction, - emojis: props.note.emojis, - users, - count: props.count, - targetElement: buttonRef.value, - }, {}, 'closed'); - }); +watch(() => props.count, (newCount, oldCount) => { + if (oldCount < newCount) anime(); +}); - return { - buttonRef, - canToggle, - toggleReaction, - }; - }, +onMounted(() => { + if (!props.isInitial) anime(); }); + +useTooltip(buttonRef, async (showing) => { + const reactions = await os.apiGet('notes/reactions', { + noteId: props.note.id, + type: props.reaction, + limit: 11, + _cacheKey_: props.count, + }); + + const users = reactions.map(x => x.user); + + os.popup(XDetails, { + showing, + reaction: props.reaction, + emojis: props.note.emojis, + users, + count: props.count, + targetElement: buttonRef.value, + }, {}, 'closed'); +}, 100); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/remote-caution.vue b/packages/client/src/components/remote-caution.vue index aa623f0fb0..130a0249b6 100644 --- a/packages/client/src/components/remote-caution.vue +++ b/packages/client/src/components/remote-caution.vue @@ -1,5 +1,5 @@ <template> -<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> +<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a class="link" :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> </template> <script lang="ts" setup> @@ -15,7 +15,7 @@ defineProps<{ background: var(--infoWarnBg); color: var(--infoWarnFg); - > a { + > .link { margin-left: 4px; color: var(--accent); } diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue index 8d9f08b4c2..3bcbe665bf 100644 --- a/packages/client/src/components/renote-button.vue +++ b/packages/client/src/components/renote-button.vue @@ -14,7 +14,7 @@ <script lang="ts"> import { computed, defineComponent, ref } from 'vue'; -import XDetails from '@/components/renote.details.vue'; +import XDetails from '@/components/users-tooltip.vue'; import { pleaseLogin } from '@/scripts/please-login'; import * as os from '@/os'; import { $i } from '@/account'; diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue index b772d1479b..dacc610165 100644 --- a/packages/client/src/components/signin.vue +++ b/packages/client/src/components/signin.vue @@ -6,7 +6,7 @@ {{ message }} </MkInfo> <div v-if="!totpLogin" class="normal-signin"> - <MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> + <MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> @@ -32,7 +32,7 @@ <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="fas fa-lock"></i></template> </MkInput> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required> <template #label>{{ i18n.ts.token }}</template> <template #prefix><i class="fas fa-gavel"></i></template> </MkInput> diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue index 3f2af306e5..dd4a2b18b8 100644 --- a/packages/client/src/components/signup.vue +++ b/packages/client/src/components/signup.vue @@ -1,11 +1,11 @@ <template> <form class="qlvuhzng _formRoot" autocomplete="new-password" @submit.prevent="onSubmit"> <template v-if="meta"> - <MkInput v-if="meta.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" spellcheck="false" required> + <MkInput v-if="meta.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required> <template #label>{{ $ts.invitationCode }}</template> <template #prefix><i class="fas fa-key"></i></template> </MkInput> - <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername"> + <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername"> <template #label>{{ $ts.username }} <div v-tooltip:dialog="$ts.usernameInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template> <template #prefix>@</template> <template #suffix>@{{ host }}</template> @@ -19,7 +19,7 @@ <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span> </template> </MkInput> - <MkInput v-if="meta.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail"> + <MkInput v-if="meta.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail"> <template #label>{{ $ts.emailAddress }} <div v-tooltip:dialog="$ts._signup.emailAddressInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template> <template #prefix><i class="fas fa-envelope"></i></template> <template #caption> @@ -67,12 +67,12 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -const getPasswordStrength = await import('syuilo-password-strength'); +import getPasswordStrength from 'syuilo-password-strength'; import { toUnicode } from 'punycode/'; -import { host, url } from '@/config'; import MkButton from './ui/button.vue'; import MkInput from './form/input.vue'; import MkSwitch from './form/switch.vue'; +import { host, url } from '@/config'; import * as os from '@/os'; import { login } from '@/account'; @@ -89,7 +89,7 @@ export default defineComponent({ type: Boolean, required: false, default: false, - } + }, }, emits: ['signup'], @@ -132,7 +132,7 @@ export default defineComponent({ this.usernameState !== 'invalid-format' && this.usernameState !== 'min-range' && this.usernameState !== 'max-range'); - } + }, }, methods: { @@ -156,7 +156,7 @@ export default defineComponent({ this.usernameState = 'wait'; os.api('username/available', { - username: this.username + username: this.username, }).then(result => { this.usernameState = result.available ? 'ok' : 'unavailable'; }).catch(err => { @@ -173,7 +173,7 @@ export default defineComponent({ this.emailState = 'wait'; os.api('email-address/available', { - emailAddress: this.email + emailAddress: this.email, }).then(result => { this.emailState = result.available ? 'ok' : result.reason === 'used' ? 'unavailable:used' : @@ -228,7 +228,7 @@ export default defineComponent({ } else { os.api('signin', { username: this.username, - password: this.password + password: this.password, }).then(res => { this.$emit('signup', res); @@ -244,11 +244,11 @@ export default defineComponent({ os.alert({ type: 'error', - text: this.$ts.somethingHappened + text: this.$ts.somethingHappened, }); }); - } - } + }, + }, }); </script> diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue index f52e5a3f9b..b52dbe31c4 100644 --- a/packages/client/src/components/sparkle.vue +++ b/packages/client/src/components/sparkle.vue @@ -33,7 +33,8 @@ </svg> --> <svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg"> - <path style="transform-origin: center; transform-box: fill-box;" + <path + style="transform-origin: center; transform-box: fill-box;" :transform="`translate(${particle.x} ${particle.y})`" :fill="particle.color" d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z" @@ -73,14 +74,15 @@ export default defineComponent({ const width = ref(0); const height = ref(0); const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202']; + let stop = false; + let ro: ResizeObserver | undefined; onMounted(() => { - const ro = new ResizeObserver((entries, observer) => { + ro = new ResizeObserver((entries, observer) => { width.value = el.value?.offsetWidth + 64; height.value = el.value?.offsetHeight + 64; }); ro.observe(el.value); - let stop = false; const add = () => { if (stop) return; const x = (Math.random() * (width.value - 64)); @@ -104,10 +106,11 @@ export default defineComponent({ }, 500 + (Math.random() * 500)); }; add(); - onUnmounted(() => { - ro.disconnect(); - stop = true; - }); + }); + + onUnmounted(() => { + if (ro) ro.disconnect(); + stop = true; }); return { diff --git a/packages/client/src/components/tag-cloud.vue b/packages/client/src/components/tag-cloud.vue new file mode 100644 index 0000000000..9f3bc1c603 --- /dev/null +++ b/packages/client/src/components/tag-cloud.vue @@ -0,0 +1,88 @@ +<template> +<div ref="rootEl" class="meijqfqm"> + <canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas> + <div :id="idForTags" ref="tagsEl" class="tags"> + <ul> + <slot></slot> + </ul> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref, watch, PropType, onBeforeUnmount } from 'vue'; +import tinycolor from 'tinycolor2'; + +const loaded = !!window.TagCanvas; +const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz'; +const computedStyle = getComputedStyle(document.documentElement); +const idForCanvas = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); +const idForTags = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); +let available = $ref(false); +let rootEl = $ref<HTMLElement | null>(null); +let canvasEl = $ref<HTMLCanvasElement | null>(null); +let tagsEl = $ref<HTMLElement | null>(null); +let width = $ref(300); + +watch($$(available), () => { + window.TagCanvas.Start(idForCanvas, idForTags, { + textColour: '#ffffff', + outlineColour: tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(), + outlineRadius: 10, + initial: [-0.030, -0.010], + frontSelect: true, + imageRadius: 8, + //dragControl: true, + dragThreshold: 3, + wheelZoom: false, + reverse: true, + depth: 0.5, + maxSpeed: 0.2, + minSpeed: 0.003, + stretchX: 0.8, + stretchY: 0.8, + }); +}); + +onMounted(() => { + width = rootEl.offsetWidth; + + if (loaded) { + available = true; + } else { + document.head.appendChild(Object.assign(document.createElement('script'), { + async: true, + src: '/client-assets/tagcanvas.min.js', + })).addEventListener('load', () => available = true); + } +}); + +onBeforeUnmount(() => { + window.TagCanvas.Delete(idForCanvas); +}); + +defineExpose({ + update: () => { + window.TagCanvas.Update(idForCanvas); + }, +}); +</script> + +<style lang="scss" scoped> +.meijqfqm { + position: relative; + overflow: hidden; overflow: clip; + display: grid; + place-items: center; + + > .canvas { + display: block; + } + + > .tags { + position: absolute; + top: 999px; + left: 999px; + } +} +</style> diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue index c9fad64eb6..e0230dccd4 100644 --- a/packages/client/src/components/toast.vue +++ b/packages/client/src/components/toast.vue @@ -54,7 +54,7 @@ onMounted(() => { width: min-content; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); border-radius: 8px; - overflow: clip; + overflow: hidden; overflow: clip; text-align: center; pointer-events: none; diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue index e6b20d9881..d8052b511c 100644 --- a/packages/client/src/components/ui/button.vue +++ b/packages/client/src/components/ui/button.vue @@ -148,7 +148,7 @@ export default defineComponent({ text-decoration: none; background: var(--buttonBg); border-radius: 5px; - overflow: clip; + overflow: hidden; overflow: clip; box-sizing: border-box; transition: background 0.1s ease; diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue index 7c595d8116..784414e791 100644 --- a/packages/client/src/components/ui/container.vue +++ b/packages/client/src/components/ui/container.vue @@ -10,7 +10,8 @@ </button> </div> </header> - <transition :name="$store.state.animation ? 'container-toggle' : ''" + <transition + :name="$store.state.animation ? 'container-toggle' : ''" @enter="enter" @after-enter="afterEnter" @leave="leave" @@ -34,37 +35,37 @@ export default defineComponent({ showHeader: { type: Boolean, required: false, - default: true + default: true, }, thin: { type: Boolean, required: false, - default: false + default: false, }, naked: { type: Boolean, required: false, - default: false + default: false, }, foldable: { type: Boolean, required: false, - default: false + default: false, }, expanded: { type: Boolean, required: false, - default: true + default: true, }, scrollable: { type: Boolean, required: false, - default: false + default: false, }, maxHeight: { type: Number, required: false, - default: null + default: null, }, }, data() { @@ -79,12 +80,12 @@ export default defineComponent({ const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; this.$el.style.minHeight = `${headerHeight}px`; if (showBody) { - this.$el.style.flexBasis = `auto`; + this.$el.style.flexBasis = 'auto'; } else { this.$el.style.flexBasis = `${headerHeight}px`; } }, { - immediate: true + immediate: true, }); this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); @@ -124,7 +125,7 @@ export default defineComponent({ afterLeave(el) { el.style.height = null; }, - } + }, }); </script> @@ -142,7 +143,8 @@ export default defineComponent({ .ukygtjoj { position: relative; - overflow: clip; + overflow: hidden; overflow: clip; + contain: content; &.naked { background: transparent !important; diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue index 6b075cb440..0cb5b48875 100644 --- a/packages/client/src/components/ui/hr.vue +++ b/packages/client/src/components/ui/hr.vue @@ -3,7 +3,8 @@ </template> <script lang="ts"> -import { defineComponent } from 'vue';import * as os from '@/os'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; export default defineComponent({}); </script> diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index dad5dfa8b0..1f3d508975 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -136,11 +136,11 @@ function focusDown() { > .item { display: block; position: relative; - padding: 8px 18px; + padding: 6px 16px; width: 100%; box-sizing: border-box; white-space: nowrap; - font-size: 0.9em; + font-size: 0.85em; line-height: 20px; text-align: left; overflow: hidden; diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue index d2b2ccff7a..b7faea736b 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/ui/modal-window.vue @@ -1,6 +1,6 @@ <template> <MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> - <div ref="rootEl" class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> + <div ref="rootEl" class="ebkgoccj _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> <div ref="headerEl" class="header"> <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <span class="title"> @@ -9,12 +9,7 @@ <button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="fas fa-check"></i></button> </div> - <div v-if="padding" class="body"> - <div class="_section"> - <slot :width="bodyWidth" :height="bodyHeight"></slot> - </div> - </div> - <div v-else class="body"> + <div class="body"> <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> @@ -28,14 +23,12 @@ import MkModal from './modal.vue'; const props = withDefaults(defineProps<{ withOkButton: boolean; okButtonDisabled: boolean; - padding: boolean; width: number; height: number | null; scroll: boolean; }>(), { withOkButton: false, okButtonDisabled: false, - padding: false, width: 400, height: null, scroll: true, @@ -96,6 +89,7 @@ defineExpose({ display: flex; flex-direction: column; contain: content; + border-radius: var(--radius); --root-margin: 24px; @@ -108,7 +102,9 @@ defineExpose({ $height-narrow: 42px; display: flex; flex-shrink: 0; - box-shadow: 0px 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); > button { height: $height; @@ -143,6 +139,7 @@ defineExpose({ > .body { overflow: auto; + background: var(--panel); } } </style> diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue index d6a29ec4b7..385f6cdb2b 100644 --- a/packages/client/src/components/ui/modal.vue +++ b/packages/client/src/components/ui/modal.vue @@ -389,7 +389,7 @@ defineExpose({ left: 0; width: 100%; height: 100%; - overflow: clip; + overflow: hidden; overflow: clip; > .content { position: fixed; diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue index c081e06acd..a03c2b3a1d 100644 --- a/packages/client/src/components/ui/pagination.vue +++ b/packages/client/src/components/ui/pagination.vue @@ -133,8 +133,10 @@ const fetchMore = async (): Promise<void> => { limit: SECOND_FETCH_LIMIT + 1, ...(props.pagination.offsetMode ? { offset: offset.value, + } : props.pagination.reversed ? { + sinceId: items.value[0].id, } : { - untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + untilId: items.value[items.value.length - 1].id, }), }).then(res => { for (let i = 0; i < res.length; i++) { @@ -169,8 +171,10 @@ const fetchMoreAhead = async (): Promise<void> => { limit: SECOND_FETCH_LIMIT + 1, ...(props.pagination.offsetMode ? { offset: offset.value, + } : props.pagination.reversed ? { + untilId: items.value[0].id, } : { - sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + sinceId: items.value[items.value.length - 1].id, }), }).then(res => { if (res.length > SECOND_FETCH_LIMIT) { diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue index 571d11ba3b..152c939a1a 100644 --- a/packages/client/src/components/ui/tooltip.vue +++ b/packages/client/src/components/ui/tooltip.vue @@ -1,7 +1,10 @@ <template> <transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="emit('closed')"> <div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> - <slot>{{ text }}</slot> + <slot> + <Mfm v-if="asMfm" :text="text"/> + <span v-else>{{ text }}</span> + </slot> </div> </transition> </template> @@ -16,6 +19,7 @@ const props = withDefaults(defineProps<{ x?: number; y?: number; text?: string; + asMfm?: boolean; maxWidth?: number; direction?: 'top' | 'bottom' | 'right' | 'left'; innerMargin?: number; @@ -170,8 +174,6 @@ const setPosition = () => { return { left, top, transformOrigin: 'left center' }; } } - - return null as never; }; const { left, top, transformOrigin } = calc(); diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue index 2066cf579d..6892b1924e 100644 --- a/packages/client/src/components/ui/window.vue +++ b/packages/client/src/components/ui/window.vue @@ -1,25 +1,20 @@ <template> <transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> <div v-if="showing" class="ebkgocck"> - <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> + <div class="body _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> <span class="left"> - <slot name="headerLeft"></slot> + <button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> </span> <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> <slot name="header"></slot> </span> <span class="right"> - <slot name="headerRight"></slot> - <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button> + <button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> + <button v-if="closeButton" class="button _button" @click="close()"><i class="fas fa-times"></i></button> </span> </div> - <div v-if="padding" class="body"> - <div class="_section"> - <slot></slot> - </div> - </div> - <div v-else class="body"> + <div class="body"> <slot></slot> </div> </div> @@ -46,41 +41,36 @@ const minHeight = 50; const minWidth = 250; function dragListen(fn) { - window.addEventListener('mousemove', fn); - window.addEventListener('touchmove', fn); + window.addEventListener('mousemove', fn); + window.addEventListener('touchmove', fn); window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); - window.addEventListener('touchend', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); + window.addEventListener('touchend', dragClear.bind(null, fn)); } function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('touchmove', fn); + window.removeEventListener('mousemove', fn); + window.removeEventListener('touchmove', fn); window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); - window.removeEventListener('touchend', dragClear); + window.removeEventListener('mouseup', dragClear); + window.removeEventListener('touchend', dragClear); } export default defineComponent({ provide: { - inWindow: true + inWindow: true, }, props: { - padding: { - type: Boolean, - required: false, - default: false - }, initialWidth: { type: Number, required: false, - default: 400 + default: 400, }, initialHeight: { type: Number, required: false, - default: null + default: null, }, canResize: { type: Boolean, @@ -105,7 +95,17 @@ export default defineComponent({ contextmenu: { type: Array, required: false, - } + }, + buttonsLeft: { + type: Array, + required: false, + default: () => [], + }, + buttonsRight: { + type: Array, + required: false, + default: () => [], + }, }, emits: ['closed'], @@ -162,7 +162,10 @@ export default defineComponent({ this.top(); }, - onHeaderMousedown(evt) { + onHeaderMousedown(evt: MouseEvent) { + // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 + if (evt.button === 2) return; + const main = this.$el as any; if (!contains(main, document.activeElement)) main.focus(); @@ -356,12 +359,12 @@ export default defineComponent({ const browserHeight = window.innerHeight; const windowWidth = main.offsetWidth; const windowHeight = main.offsetHeight; - if (position.left < 0) main.style.left = 0; // 左はみ出し - if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し - if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し - if (position.top < 0) main.style.top = 0; // 上はみ出し - } - } + if (position.left < 0) main.style.left = 0; // 左はみ出し + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し + if (position.top < 0) main.style.top = 0; // 上はみ出し + }, + }, }); </script> @@ -386,10 +389,11 @@ export default defineComponent({ flex-direction: column; contain: content; width: 100%; - height: 100%; + height: 100%; + border-radius: var(--radius); > .header { - --height: 50px; + --height: 45px; &.mini { --height: 38px; @@ -401,20 +405,33 @@ export default defineComponent({ flex-shrink: 0; user-select: none; height: var(--height); - border-bottom: solid 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + //border-bottom: solid 1px var(--divider); + font-size: 95%; + font-weight: bold; > .left, > .right { - > ::v-deep(button) { + > .button { height: var(--height); width: var(--height); &:hover { color: var(--fgHighlighted); } + + &.highlighted { + color: var(--accent); + } } } > .left { + margin-right: 16px; + } + + > .right { min-width: 16px; } @@ -432,6 +449,7 @@ export default defineComponent({ > .body { flex: 1; overflow: auto; + background: var(--panel); } } diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue index 6c593c7b41..e15d28a382 100644 --- a/packages/client/src/components/url-preview.vue +++ b/packages/client/src/components/url-preview.vue @@ -1,14 +1,14 @@ <template> <div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> <button class="disablePlayer" :title="$ts.disablePlayer" @click="playerEnabled = false"><i class="fas fa-times"></i></button> - <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> + <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> </div> <div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter"> <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> </div> <div v-else v-size="{ max: [400, 350] }" class="mk-url-preview"> <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> - <component :is="self ? 'MkA' : 'a'" v-if="!fetching" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> + <component :is="self ? 'MkA' : 'a'" v-if="!fetching" class="link" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> <div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`"> <button v-if="!playerEnabled && player.url" class="_button" :title="$ts.enablePlayer" @click.prevent="playerEnabled = true"><i class="fas fa-play-circle"></i></button> </div> @@ -57,7 +57,7 @@ let sitename = $ref<string | null>(null); let player = $ref({ url: null, width: null, - height: null + height: null, }); let playerEnabled = $ref(false); let tweetId = $ref<string | null>(null); @@ -143,7 +143,7 @@ onUnmounted(() => { .mk-url-preview { &.max-width_400px { - > a { + > .link { font-size: 12px; > .thumbnail { @@ -157,7 +157,7 @@ onUnmounted(() => { } &.max-width_350px { - > a { + > .link { font-size: 10px; > .thumbnail { @@ -205,7 +205,7 @@ onUnmounted(() => { } } - > a { + > .link { position: relative; display: block; font-size: 14px; diff --git a/packages/client/src/components/user-card-mini.vue b/packages/client/src/components/user-card-mini.vue new file mode 100644 index 0000000000..732adf7f5b --- /dev/null +++ b/packages/client/src/components/user-card-mini.vue @@ -0,0 +1,99 @@ +<template> +<div :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + </div> + <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; +import { acct } from '@/filters/user'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +let chartValues = $ref<number[] | null>(null); + +os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { + // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く + res.inc.splice(0, 1); + chartValues = res.inc; +}); +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + padding: 16px; + background: var(--panel); + border-radius: 8px; + + > :global(.avatar) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.name) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > :global(.chart) { + height: 30px; + } + + &:global(.yellow) { + --c: rgb(255 196 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.red) { + --c: rgb(255 0 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.gray) { + --c: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } +} +</style> diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/user-select-dialog.vue index b34d21af07..972d353486 100644 --- a/packages/client/src/components/user-select-dialog.vue +++ b/packages/client/src/components/user-select-dialog.vue @@ -11,7 +11,7 @@ <div class="tbhwbxda"> <div class="form"> <FormSplit :min-width="170"> - <MkInput ref="usernameEl" v-model="username" @update:modelValue="search"> + <MkInput v-model="username" :autofocus="true" @update:modelValue="search"> <template #label>{{ $ts.username }}</template> <template #prefix>@</template> </MkInput> @@ -70,15 +70,8 @@ let host = $ref(''); let users: misskey.entities.UserDetailed[] = $ref([]); let recentUsers: misskey.entities.UserDetailed[] = $ref([]); let selected: misskey.entities.UserDetailed | null = $ref(null); -let usernameEl: HTMLElement = $ref(); let dialogEl = $ref(); -const focus = () => { - if (usernameEl) { - usernameEl.focus(); - } -}; - const search = () => { if (username === '' && host === '') { users = []; @@ -112,12 +105,6 @@ const cancel = () => { }; onMounted(() => { - focus(); - - nextTick(() => { - focus(); - }); - os.api('users/show', { userIds: defaultStore.state.recentlyUsedUsers, }).then(users => { diff --git a/packages/client/src/components/renote.details.vue b/packages/client/src/components/users-tooltip.vue index 2df19bcd3f..2df19bcd3f 100644 --- a/packages/client/src/components/renote.details.vue +++ b/packages/client/src/components/users-tooltip.vue diff --git a/packages/client/src/components/visibility.vue b/packages/client/src/components/visibility.vue new file mode 100644 index 0000000000..b41c950331 --- /dev/null +++ b/packages/client/src/components/visibility.vue @@ -0,0 +1,47 @@ +<template> +<span v-if="note.visibility !== 'public'" :class="$style.visibility"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="fas fa-envelope"></i> +</span> +<span v-if="note.localOnly" :class="$style.localOnly"><i class="fas fa-biohazard"></i></span> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import XDetails from '@/components/users-tooltip.vue'; +import * as os from '@/os'; +import { useTooltip } from '@/scripts/use-tooltip'; + +const props = defineProps<{ + note: { + visibility: string; + localOnly?: boolean; + visibleUserIds?: string[]; + }, +}>(); + +const specified = $ref<HTMLElement>(); + +if (props.note.visibility === 'specified') { + useTooltip($$(specified), async (showing) => { + const users = await os.api('users/show', { + userIds: props.note.visibleUserIds, + limit: 10, + }); + + os.popup(XDetails, { + showing, + users, + count: props.note.visibleUserIds.length, + targetElement: specified, + }, {}, 'closed'); + }); +} +</script> + +<style lang="scss" module> +.visibility, .localOnly { + margin-left: 0.5em; +} +</style> diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue index 74dd79f733..85b8ae0ed3 100644 --- a/packages/client/src/components/widgets.vue +++ b/packages/client/src/components/widgets.vue @@ -19,7 +19,9 @@ <div class="customize-container"> <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button> <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button> - <component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="handle" :widget="element" @updateProps="updateWidget(element.id, $event)"/> + <div class="handle"> + <component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :widget="element" @updateProps="updateWidget(element.id, $event)"/> + </div> </div> </template> </XDraggable> @@ -111,6 +113,7 @@ export default defineComponent({ } > .widget, .customize-container { + contain: content; margin: var(--margin) 0; &:first-of-type { @@ -141,6 +144,12 @@ export default defineComponent({ > .remove { right: 8px; } + + > .handle { + > .widget { + pointer-events: none; + } + } } } </style> |