diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2022-06-21 07:49:52 +0000 |
|---|---|---|
| committer | tamaina <tamaina@hotmail.co.jp> | 2022-06-21 07:49:52 +0000 |
| commit | f33654fb9ae562a43745b612a3007c248d988f2b (patch) | |
| tree | 25b8599f7d28bf02cd0d3970b735d9db5e84742b /packages/client/src/components | |
| parent | Merge branch 'develop' into pizzax-indexeddb (diff) | |
| parent | refactor(client): use composition api (diff) | |
| download | misskey-f33654fb9ae562a43745b612a3007c248d988f2b.tar.gz misskey-f33654fb9ae562a43745b612a3007c248d988f2b.tar.bz2 misskey-f33654fb9ae562a43745b612a3007c248d988f2b.zip | |
Merge branch 'develop' into pizzax-indexeddb
Diffstat (limited to 'packages/client/src/components')
48 files changed, 1205 insertions, 1142 deletions
diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/abuse-report.vue index a947406f88..86eccd6276 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}`" :behavior="'window'"> + <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,84 @@ <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; + background-image: linear-gradient(45deg, rgb(255 196 0 / 15%) 16.67%, transparent 16.67%, transparent 50%, rgb(255 196 0 / 15%) 50%, rgb(255 196 0 / 15%) 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..ae708026e0 100644 --- a/packages/client/src/components/autocomplete.vue +++ b/packages/client/src/components/autocomplete.vue @@ -35,6 +35,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 +43,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 +55,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 +68,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 ccd8880df8..183658471b 100644 --- a/packages/client/src/components/captcha.vue +++ b/packages/client/src/components/captcha.vue @@ -27,8 +27,7 @@ type CaptchaContainer = { }; declare global { - interface Window extends CaptchaContainer { - } + interface Window extends CaptchaContainer { } } const props = defineProps<{ diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue index 4e9c4e587a..5e9c2f03be 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, @@ -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,7 +156,7 @@ const getDate = (ago: number) => { const format = (arr) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), - y: v + y: v, })); }; @@ -343,7 +343,7 @@ const render = () => { min: 'original', max: 'original', }, - } + }, } : undefined, //gradient, }, @@ -367,8 +367,8 @@ const render = () => { ctx.stroke(); ctx.restore(); } - } - }] + }, + }], }); }; @@ -433,18 +433,18 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => { 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), + }], }; }; @@ -456,7 +456,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { 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 +464,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 +472,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 +480,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 +488,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, }], @@ -522,21 +522,21 @@ const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { 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)), ), }], }; @@ -607,8 +607,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 +', @@ -642,8 +642,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 +', @@ -672,18 +672,18 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { 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), + }], }; }; @@ -696,9 +696,9 @@ 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)), + ), + }], }; }; @@ -711,9 +711,9 @@ 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)), + ), + }], }; }; @@ -726,17 +726,17 @@ 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)), + ), + }], }; }; @@ -750,9 +750,9 @@ 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)), + ), + }], }; }; @@ -765,9 +765,9 @@ 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)), + ), + }], }; }; diff --git a/packages/client/src/components/cropper-dialog.vue b/packages/client/src/components/cropper-dialog.vue new file mode 100644 index 0000000000..a8bde6ea05 --- /dev/null +++ b/packages/client/src/components/cropper-dialog.vue @@ -0,0 +1,175 @@ +<template> +<XModalWindow + ref="dialogEl" + :width="800" + :height="500" + :scroll="false" + :with-ok-button="true" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.cropImage }}</template> + <template #default="{ width, height }"> + <div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`"> + <Transition name="fade"> + <div v-if="loading" class="loading"> + <MkLoading/> + </div> + </Transition> + <div class="container"> + <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad"> + </div> + </div> + </template> +</XModalWindow> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import Cropper from 'cropperjs'; +import tinycolor from 'tinycolor2'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +import { apiUrl, url } from '@/config'; +import { query } from '@/scripts/url'; + +const emit = defineEmits<{ + (ev: 'ok', cropped: misskey.entities.DriveFile): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const props = defineProps<{ + file: misskey.entities.DriveFile; + aspectRatio: number; +}>(); + +const imgUrl = `${url}/proxy/image.webp?${query({ + url: props.file.url, +})}`; +let dialogEl = $ref<InstanceType<typeof XModalWindow>>(); +let imgEl = $ref<HTMLImageElement>(); +let cropper: Cropper | null = null; +let loading = $ref(true); + +const ok = async () => { + const promise = new Promise<misskey.entities.DriveFile>(async (res) => { + const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas(); + croppedCanvas.toBlob(blob => { + const formData = new FormData(); + formData.append('file', blob); + formData.append('i', $i.token); + if (defaultStore.state.uploadFolder) { + formData.append('folderId', defaultStore.state.uploadFolder); + } + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: formData, + }) + .then(response => response.json()) + .then(f => { + res(f); + }); + }); + }); + + os.promiseDialog(promise); + + const f = await promise; + + emit('ok', f); + dialogEl.close(); +}; + +const cancel = () => { + emit('cancel'); + dialogEl.close(); +}; + +const onImageLoad = () => { + loading = false; + + if (cropper) { + cropper.getCropperImage()!.$center('contain'); + cropper.getCropperSelection()!.$center(); + } +}; + +onMounted(() => { + cropper = new Cropper(imgEl, { + }); + + const computedStyle = getComputedStyle(document.documentElement); + + const selection = cropper.getCropperSelection()!; + selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + selection.aspectRatio = props.aspectRatio; + selection.initialAspectRatio = props.aspectRatio; + selection.outlined = true; + + window.setTimeout(() => { + cropper.getCropperImage()!.$center('contain'); + selection.$center(); + }, 100); + + // モーダルオープンアニメーションが終わったあとで再度調整 + window.setTimeout(() => { + cropper.getCropperImage()!.$center('contain'); + selection.$center(); + }, 500); +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.5s ease 0.5s; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.mk-cropper-dialog { + display: flex; + flex-direction: column; + width: var(--vw); + height: var(--vh); + position: relative; + + > .loading { + position: absolute; + z-index: 10; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + background: rgba(0, 0, 0, 0.5); + } + + > .container { + flex: 1; + width: 100%; + height: 100%; + + > ::v-deep(cropper-canvas) { + width: 100%; + height: 100%; + + > cropper-selection > cropper-handle[action="move"] { + background: transparent; + } + } + } +} +</style> diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue index dd24440e82..b346585cec 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,7 +57,7 @@ const isThumbnailAvailable = computed(() => { .zdjebgpv { position: relative; display: flex; - background: #e1e1e1; + background: var(--panel); border-radius: 8px; overflow: clip; diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue index d530f8beff..3ccb5d6219 100644 --- a/packages/client/src/components/drive.folder.vue +++ b/packages/client/src/components/drive.folder.vue @@ -71,7 +71,7 @@ function onMouseover() { } function onMouseout() { - hover.value = false + hover.value = false; } function onDragover(ev: DragEvent) { @@ -204,7 +204,7 @@ function deleteFolder() { defaultStore.set('uploadFolder', null); } }).catch(err => { - switch(err.id) { + switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': os.alert({ type: 'error', diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue index 42ec3a5995..6c2c8acad0 100644 --- a/packages/client/src/components/drive.vue +++ b/packages/client/src/components/drive.vue @@ -143,7 +143,7 @@ const fetching = ref(true); const ilFilesObserver = new IntersectionObserver( (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles() -) +); watch(folder, () => emit('cd', folder.value)); @@ -332,7 +332,7 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { // 削除時に親フォルダに移動 move(folderToDelete.parentId); }).catch(err => { - switch(err.id) { + switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': os.alert({ type: 'error', @@ -607,7 +607,7 @@ function onContextmenu(ev: MouseEvent) { onMounted(() => { if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) { nextTick(() => { - ilFilesObserver.observe(loadMoreFiles.value?.$el) + ilFilesObserver.observe(loadMoreFiles.value?.$el); }); } @@ -628,7 +628,7 @@ onMounted(() => { onActivated(() => { if (defaultStore.state.enableInfiniteScroll) { nextTick(() => { - ilFilesObserver.observe(loadMoreFiles.value?.$el) + ilFilesObserver.observe(loadMoreFiles.value?.$el); }); } }); diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue index 522f636474..64732e7033 100644 --- a/packages/client/src/components/emoji-picker.vue +++ b/packages/client/src/components/emoji-picker.vue @@ -1,6 +1,6 @@ <template> <div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"> - <input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" @paste.stop="paste" @keyup.enter="done()"> + <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"> diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/follow-button.vue index b3540bc316..efee795e43 100644 --- a/packages/client/src/components/follow-button.vue +++ b/packages/client/src/components/follow-button.vue @@ -28,7 +28,7 @@ </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, ref } from 'vue'; +import { onBeforeUnmount, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os'; import { stream } from '@/stream'; @@ -43,32 +43,30 @@ const props = withDefaults(defineProps<{ large: false, }); -const isFollowing = ref(props.user.isFollowing); -const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFromYou); -const wait = ref(false); +let isFollowing = $ref(props.user.isFollowing); +let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); +let wait = $ref(false); const connection = stream.useChannel('main'); if (props.user.isFollowing == null) { os.api('users/show', { userId: props.user.id - }).then(u => { - isFollowing.value = u.isFollowing; - hasPendingFollowRequestFromYou.value = u.hasPendingFollowRequestFromYou; - }); + }) + .then(onFollowChange); } function onFollowChange(user: Misskey.entities.UserDetailed) { if (user.id === props.user.id) { - isFollowing.value = user.isFollowing; - hasPendingFollowRequestFromYou.value = user.hasPendingFollowRequestFromYou; + isFollowing = user.isFollowing; + hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; } } async function onClick() { - wait.value = true; + wait = true; try { - if (isFollowing.value) { + if (isFollowing) { const { canceled } = await os.confirm({ type: 'warning', text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), @@ -80,26 +78,22 @@ async function onClick() { userId: props.user.id }); } else { - if (hasPendingFollowRequestFromYou.value) { + if (hasPendingFollowRequestFromYou) { await os.api('following/requests/cancel', { userId: props.user.id }); - } else if (props.user.isLocked) { - await os.api('following/create', { - userId: props.user.id - }); - hasPendingFollowRequestFromYou.value = true; + hasPendingFollowRequestFromYou = false; } else { await os.api('following/create', { userId: props.user.id }); - hasPendingFollowRequestFromYou.value = true; + hasPendingFollowRequestFromYou = true; } } } catch (err) { console.error(err); } finally { - wait.value = false; + wait = false; } } diff --git a/packages/client/src/components/form/folder.vue b/packages/client/src/components/form/folder.vue index 571afe50c0..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> @@ -24,7 +24,7 @@ const props = withDefaults(defineProps<{ defaultOpen: boolean; }>(), { defaultOpen: false, -}) +}); let opened = $ref(props.defaultOpen); let openedAtLeastOnce = $ref(props.defaultOpen); diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue index ff5d51f9c7..a52acae9e1 100644 --- a/packages/client/src/components/form/radios.vue +++ b/packages/client/src/components/form/radios.vue @@ -14,7 +14,7 @@ export default defineComponent({ data() { return { value: this.modelValue, - } + }; }, watch: { value() { diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue index 07f2c23124..9bf7651119 100644 --- a/packages/client/src/components/form/range.vue +++ b/packages/client/src/components/form/range.vue @@ -1,7 +1,7 @@ <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> @@ -192,6 +192,8 @@ export default defineComponent({ > .body { padding: 12px; + background: var(--panel); + border: solid 1px var(--panel); border-radius: 6px; > .container { 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/loading.vue b/packages/client/src/components/global/loading.vue index fa2ce1800c..5a7e362fcf 100644 --- a/packages/client/src/components/global/loading.vue +++ b/packages/client/src/components/global/loading.vue @@ -1,12 +1,12 @@ <template> -<div class="yxspomdl" :class="{ inline, colored, mini }"> - <div class="container"> - <svg class="spinner bg" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> +<div :class="[$style.root, { [$style.inline]: inline, [$style.colored]: colored, [$style.mini]: mini }]"> + <div :class="$style.container"> + <svg :class="[$style.spinner, $style.bg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> <g transform="matrix(1.125,0,0,1.125,12,12)"> <circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> </g> </svg> - <svg class="spinner fg" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> + <svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg"> <g transform="matrix(1.125,0,0,1.125,12,12)"> <path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/> </g> @@ -16,7 +16,9 @@ </template> <script lang="ts" setup> -import { } from 'vue'; +import { useCssModule } from 'vue'; + +useCssModule(); const props = withDefaults(defineProps<{ inline?: boolean; @@ -29,7 +31,7 @@ const props = withDefaults(defineProps<{ }); </script> -<style lang="scss" scoped> +<style lang="scss" module> @keyframes spinner { 0% { transform: rotate(0deg); @@ -39,7 +41,7 @@ const props = withDefaults(defineProps<{ } } -.yxspomdl { +.root { padding: 32px; text-align: center; cursor: wait; @@ -60,33 +62,33 @@ const props = withDefaults(defineProps<{ padding: 16px; --size: 32px; } +} - > .container { - position: relative; - width: var(--size); - height: var(--size); - margin: 0 auto; +.container { + position: relative; + width: var(--size); + height: var(--size); + margin: 0 auto; +} - > .spinner { - position: absolute; - top: 0; - left: 0; - width: var(--size); - height: var(--size); - fill-rule: evenodd; - clip-rule: evenodd; - stroke-linecap: round; - stroke-linejoin: round; - stroke-miterlimit: 1.5; - } +.spinner { + position: absolute; + top: 0; + left: 0; + width: var(--size); + height: var(--size); + fill-rule: evenodd; + clip-rule: evenodd; + stroke-linecap: round; + stroke-linejoin: round; + stroke-miterlimit: 1.5; +} - > .bg { - opacity: 0.275; - } +.bg { + opacity: 0.275; +} - > .fg { - animation: spinner 0.5s linear infinite; - } - } +.fg { + animation: spinner 0.5s linear infinite; } </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..c01631c6a3 --- /dev/null +++ b/packages/client/src/components/global/page-header.vue @@ -0,0 +1,300 @@ +<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.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 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-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 } 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, PageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + tabs?: { + title: string; + active: boolean; + icon?: string; + iconOnly?: boolean; + onClick: () => void; + }[]; + actions?: { + text: string; + icon: string; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; +}>(); + +const metadata = injectPageMetadata(); + +const hideTitle = inject('shouldOmitHeaderTitle', false); +const thin_ = props.thin || inject('shouldHeaderThin', false); + +const el = $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, + action: tab.onClick, + })); + popupMenu(menu, ev.currentTarget ?? ev.target); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + scrollToTop(el, { behavior: 'smooth' }); +}; + +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); + + if (el && el.parentElement) { + narrow = el.parentElement.offsetWidth < 500; + ro = new ResizeObserver((entries, observer) => { + if (el.parentElement) { + 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: 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/router-view.vue b/packages/client/src/components/global/router-view.vue new file mode 100644 index 0000000000..393ba30c3d --- /dev/null +++ b/packages/client/src/components/global/router-view.vue @@ -0,0 +1,39 @@ +<template> +<KeepAlive max="5"> + <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'; + +const props = defineProps<{ + router?: Router; +}>(); + +const emit = defineEmits<{ +}>(); + +const router = props.router ?? inject('router'); + +if (router == null) { + throw new Error('no router provided'); +} + +let currentPageComponent = $ref(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/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue index 89d397f082..98a7ee9c30 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="bodyEl" :data-sticky-container-header-height="headerHeight"> <slot></slot> </div> </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted } from 'vue'; -export default defineComponent({ - props: { - autoSticky: { - type: Boolean, - required: false, - default: false, - }, - }, +const props = withDefaults(defineProps<{ + autoSticky?: boolean; +}>(), { + autoSticky: false, +}); - setup(props, context) { - const rootEl = ref<HTMLElement>(null); - const bodyEl = ref<HTMLElement>(null); +const rootEl = $ref<HTMLElement>(); +const bodyEl = $ref<HTMLElement>(); - const calc = () => { - const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; +let headerHeight = $ref<string | undefined>(); - 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 = () => { + const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px'; - if (props.autoSticky) { - header.style.setProperty('--stickyTop', currentStickyTop); - header.style.position = 'sticky'; - header.style.top = 'var(--stickyTop)'; - header.style.zIndex = '1'; - } - } - }; + const header = rootEl.children[0] as HTMLElement; + if (header === bodyEl) { + bodyEl.style.setProperty('--stickyTop', currentStickyTop); + } else { + bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); + headerHeight = header.offsetHeight.toString(); - onMounted(() => { - calc(); + if (props.autoSticky) { + header.style.setProperty('--stickyTop', currentStickyTop); + header.style.position = 'sticky'; + header.style.top = 'var(--stickyTop)'; + header.style.zIndex = '1'; + } + } +}; - const observer = new MutationObserver(() => { - window.setTimeout(() => { - calc(); - }, 100); - }); +const observer = new MutationObserver(() => { + window.setTimeout(() => { + calc(); + }, 100); +}); - observer.observe(rootEl.value, { - attributes: false, - childList: true, - subtree: false, - }); +onMounted(() => { + calc(); - onUnmounted(() => { - observer.disconnect(); - }); - }); + observer.observe(rootEl, { + attributes: false, + childList: true, + subtree: false, + }); +}); - 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 02351deb5f..a7f142f961 100644 --- a/packages/client/src/components/global/time.vue +++ b/packages/client/src/components/global/time.vue @@ -32,8 +32,7 @@ const relative = $computed(() => { 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 < -1 ? i18n.ts._ago.future : - i18n.ts._ago.unknown); + i18n.ts._ago.future); }); function tick() { 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/media-video.vue b/packages/client/src/components/media-video.vue index 680eb27e64..5c38691e69 100644 --- a/packages/client/src/components/media-video.vue +++ b/packages/client/src/components/media-video.vue @@ -8,7 +8,8 @@ <div v-else class="kkjnbbplepmiyuadieoenjgutgcmtsvu"> <video :poster="video.thumbnailUrl" - :title="video.name" + :title="video.comment" + :alt="video.comment" preload="none" controls @contextmenu.stop diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue index 8c74eae876..345b6a0b01 100644 --- a/packages/client/src/components/mini-chart.vue +++ b/packages/client/src/components/mini-chart.vue @@ -9,82 +9,71 @@ <polygon :points="polygonPoints" fill="#fff" - fill-opacity="0.5"/> + fill-opacity="0.5" + /> <polyline :points="polylinePoints" fill="none" stroke="#fff" - stroke-width="2"/> + stroke-width="2" + /> <circle :cx="headX" :cy="headY" r="3" - fill="#fff"/> + fill="#fff" + /> </mask> </defs> <rect x="-10" y="-10" :width="viewBoxX + 20" :height="viewBoxY + 20" - :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/> + :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`" + /> </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'; -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[]; +}>(); + +const viewBoxX = 50; +const viewBoxY = 50; +const gradientId = uuid(); +const maskId = 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); + +function draw(): void { + const stats = props.src.slice().reverse(); + const peak = Math.max.apply(null, stats) || 1; + + const _polylinePoints = stats.map((n, i) => [ + i * (viewBoxX / (stats.length - 1)), + (1 - (n / peak)) * viewBoxY, + ]); + + polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - // 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; + polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; - const polylinePoints = stats.map((n, i) => [ - i * (this.viewBoxX / (stats.length - 1)), - (1 - (n / peak)) * this.viewBoxY - ]); + headX = _polylinePoints[_polylinePoints.length - 1][0]; + headY = _polylinePoints[_polylinePoints.length - 1][1]; +} - this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); +watch(() => props.src, draw, { immediate: true }); - this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; +// Vueが何故かWatchを発動させない場合があるので +clock = window.setInterval(draw, 1000); - this.headX = polylinePoints[polylinePoints.length - 1][0]; - this.headY = polylinePoints[polylinePoints.length - 1][1]; - } - } +onUnmounted(() => { + window.clearInterval(clock); }); </script> diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue index 2e17d5d030..aef70f113b 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 _window _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> diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue index 14bbbd4f3c..ba47bfcd4a 100644 --- a/packages/client/src/components/note-detailed.vue +++ b/packages/client/src/components/note-detailed.vue @@ -222,7 +222,7 @@ function react(viaKeyboard = false): void { reactionPicker.show(reactButton.value, reaction => { os.api('notes/reactions/create', { noteId: appearNote.id, - reaction: reaction + reaction: reaction, }); }, () => { focus(); @@ -233,7 +233,7 @@ function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; os.api('notes/reactions/delete', { - noteId: note.id + noteId: note.id, }); } @@ -251,13 +251,13 @@ 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, { - viaKeyboard + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, { + viaKeyboard, }).then(focus); } @@ -269,12 +269,12 @@ function showRenoteMenu(viaKeyboard = false): void { danger: true, action: () => { os.api('notes/delete', { - noteId: note.id + noteId: note.id, }); isDeleted.value = true; - } + }, }], renoteTime.value, { - viaKeyboard: viaKeyboard + viaKeyboard: viaKeyboard, }); } @@ -288,14 +288,14 @@ function blur() { os.api('notes/children', { noteId: appearNote.id, - limit: 30 + limit: 30, }).then(res => { replies.value = res; }); if (appearNote.replyId) { os.api('notes/conversation', { - noteId: appearNote.replyId + noteId: appearNote.replyId, }).then(res => { conversation.value = res.reverse(); }); diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue index c6907787b5..b813b9a2b9 100644 --- a/packages/client/src/components/note-simple.vue +++ b/packages/client/src/components/note-simple.vue @@ -5,7 +5,7 @@ <XNoteHeader class="header" :note="note" :mini="true"/> <div class="body"> <p v-if="note.cw != null" class="cw"> - <span v-if="note.cw != ''" class="text">{{ note.cw }}</span> + <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> <XCwButton v-model="showContent" :note="note"/> </p> <div v-show="note.cw == null || showContent" class="content"> diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index bc8a0dd19d..c2c92f541d 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -105,7 +105,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'; @@ -210,7 +210,7 @@ function react(viaKeyboard = false): void { reactionPicker.show(reactButton.value, reaction => { os.api('notes/reactions/create', { noteId: appearNote.id, - reaction: reaction + reaction: reaction, }); }, () => { focus(); @@ -221,10 +221,12 @@ function undoReact(note): void { const oldReaction = note.myReaction; if (!oldReaction) return; os.api('notes/reactions/delete', { - noteId: note.id + noteId: note.id, }); } +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,13 +241,13 @@ 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, { - viaKeyboard + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { + viaKeyboard, }).then(focus); } @@ -257,12 +259,12 @@ function showRenoteMenu(viaKeyboard = false): void { danger: true, action: () => { os.api('notes/delete', { - noteId: note.id + noteId: note.id, }); isDeleted.value = true; - } + }, }], renoteTime.value, { - viaKeyboard: viaKeyboard + viaKeyboard: viaKeyboard, }); } @@ -284,7 +286,7 @@ function focusAfter() { function readPromo() { os.api('promo/read', { - noteId: appearNote.id + noteId: appearNote.id, }); isDeleted.value = true; } diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue index ec1efec261..64d828394b 100644 --- a/packages/client/src/components/notification-setting-window.vue +++ b/packages/client/src/components/notification-setting-window.vue @@ -1,5 +1,6 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="400" :height="450" :with-ok-button="true" @@ -28,18 +29,18 @@ <script lang="ts"> import { defineComponent, PropType } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.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 { notificationTypes } from 'misskey-js'; +import XModalWindow from '@/components/ui/modal-window.vue'; export default defineComponent({ components: { XModalWindow, MkSwitch, MkInfo, - MkButton + MkButton, }, props: { @@ -53,7 +54,7 @@ export default defineComponent({ type: Boolean, required: false, default: true, - } + }, }, emits: ['done', 'closed'], @@ -93,7 +94,7 @@ export default defineComponent({ for (const type in this.typesMap) { this.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 3791c576ee..cbfd809f37 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/notification.vue @@ -16,7 +16,8 @@ <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i> <i v-else-if="notification.type === 'pollEnded'" class="fas fa-poll-h"></i> <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> - <XReactionIcon v-else-if="notification.type === 'reaction'" + <XReactionIcon + v-else-if="notification.type === 'reaction'" ref="reactionRef" :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" :custom-emojis="notification.note.emojis" @@ -74,10 +75,10 @@ <script lang="ts"> import { defineComponent, ref, onMounted, onUnmounted, watch } from 'vue'; import * as misskey from 'misskey-js'; -import { getNoteSummary } from '@/scripts/get-note-summary'; import XReactionIcon from './reaction-icon.vue'; import MkFollowButton from './follow-button.vue'; import XReactionTooltip from './reaction-tooltip.vue'; +import { getNoteSummary } from '@/scripts/get-note-summary'; import { notePage } from '@/filters/note'; import { userPage } from '@/filters/user'; import { i18n } from '@/i18n'; @@ -87,7 +88,7 @@ import { useTooltip } from '@/scripts/use-tooltip'; export default defineComponent({ components: { - XReactionIcon, MkFollowButton + XReactionIcon, MkFollowButton, }, props: { @@ -116,7 +117,7 @@ export default defineComponent({ const readObserver = new IntersectionObserver((entries, observer) => { if (!entries.some(entry => entry.isIntersecting)) return; stream.send('readNotification', { - id: props.notification.id + id: props.notification.id, }); observer.disconnect(); }); diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue index dc900a670d..8eb569c369 100644 --- a/packages/client/src/components/notifications.vue +++ b/packages/client/src/components/notifications.vue @@ -19,8 +19,7 @@ <script lang="ts" setup> import { defineComponent, markRaw, onUnmounted, onMounted, computed, ref } from 'vue'; import { notificationTypes } from 'misskey-js'; -import MkPagination from '@/components/ui/pagination.vue'; -import { Paging } from '@/components/ui/pagination.vue'; +import MkPagination, { Paging } from '@/components/ui/pagination.vue'; import XNotification from '@/components/notification.vue'; import XList from '@/components/date-separated-list.vue'; import XNote from '@/components/note.vue'; @@ -49,14 +48,14 @@ const onNotification = (notification) => { const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); if (isMuted || document.visibilityState === 'visible') { stream.send('readNotification', { - id: notification.id + id: notification.id, }); } if (!isMuted) { pagingComponent.value.prepend({ ...notification, - isRead: document.visibilityState === 'visible' + isRead: document.visibilityState === 'visible', }); } }; diff --git a/packages/client/src/components/number-diff.vue b/packages/client/src/components/number-diff.vue index 9889c97ec3..e7d4a5472a 100644 --- a/packages/client/src/components/number-diff.vue +++ b/packages/client/src/components/number-diff.vue @@ -12,7 +12,7 @@ export default defineComponent({ props: { value: { type: Number, - required: true + required: true, }, }, @@ -26,7 +26,7 @@ export default defineComponent({ isZero, number, }; - } + }, }); </script> diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue index 7455236bad..7de09d3be4 100644 --- a/packages/client/src/components/page-window.vue +++ b/packages/client/src/components/page-window.vue @@ -1,186 +1,135 @@ <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<string[]>([props.initialPath]); +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(router.getCurrentPath()); +}); - 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]); +} - 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> diff --git a/packages/client/src/components/page/page.post.vue b/packages/client/src/components/page/page.post.vue index 8ac8c46692..3401f945bd 100644 --- a/packages/client/src/components/page/page.post.vue +++ b/packages/client/src/components/page/page.post.vue @@ -66,7 +66,7 @@ export default defineComponent({ .then(response => response.json()) .then(f => { ok(f); - }) + }); }); }); os.promiseDialog(promise); diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue index 3807769118..6b9827407b 100644 --- a/packages/client/src/components/post-form-attaches.vue +++ b/packages/client/src/components/post-form-attaches.vue @@ -16,7 +16,7 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -import MkDriveFileThumbnail from './drive-file-thumbnail.vue' +import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; import * as os from '@/os'; export default defineComponent({ @@ -114,19 +114,19 @@ 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); } } diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue index 64ee873fd7..0197313e0e 100644 --- a/packages/client/src/components/post-form.vue +++ b/packages/client/src/components/post-form.vue @@ -442,7 +442,7 @@ function onCompositionEnd(ev: CompositionEvent) { } async function onPaste(ev: ClipboardEvent) { - for (const { item, i } of Array.from(ev.clipboardData.items).map((item, i) => ({item, i}))) { + for (const { item, i } of Array.from(ev.clipboardData.items).map((item, i) => ({ item, i }))) { if (item.kind === 'file') { const file = item.getAsFile(); const lio = file.name.lastIndexOf('.'); diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue index 7e0ed58cbd..7bb548cf06 100644 --- a/packages/client/src/components/queue-chart.vue +++ b/packages/client/src/components/queue-chart.vue @@ -222,7 +222,7 @@ export default defineComponent({ return { chartEl, - } + }; }, }); </script> diff --git a/packages/client/src/components/sample.vue b/packages/client/src/components/sample.vue index 65249ff7e9..f80b9c96b7 100644 --- a/packages/client/src/components/sample.vue +++ b/packages/client/src/components/sample.vue @@ -52,7 +52,7 @@ export default defineComponent({ flag: true, radio: 'misskey', mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.` - } + }; }, methods: { diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue index d283a758a6..b772d1479b 100644 --- a/packages/client/src/components/signin.vue +++ b/packages/client/src/components/signin.vue @@ -14,8 +14,6 @@ <template #prefix><i class="fas fa-lock"></i></template> <template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template> </MkInput> - <MkCaptcha v-if="meta.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="meta.hcaptchaSiteKey"/> - <MkCaptcha v-if="meta.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="meta.recaptchaSiteKey"/> <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton> </div> <div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }"> @@ -64,8 +62,6 @@ import { showSuspendedDialog } from '../scripts/show-suspended-dialog'; import { instance } from '@/instance'; import { i18n } from '@/i18n'; -const MkCaptcha = defineAsyncComponent(() => import('./captcha.vue')); - let signing = $ref(false); let user = $ref(null); let username = $ref(''); @@ -163,7 +159,7 @@ function queryKey() { function onSubmit() { signing = true; - console.log('submit') + console.log('submit'); if (!totpLogin && user && user.twoFactorEnabled) { if (window.PublicKeyCredential && user.securityKeys) { os.api('signin', { @@ -217,8 +213,16 @@ function loginFailed(err) { showSuspendedDialog(); break; } + case '22d05606-fbcf-421a-a2db-b32610dcfd1b': { + os.alert({ + type: 'error', + title: i18n.ts.loginFailed, + text: i18n.ts.rateLimitExceeded, + }); + break; + } default: { - console.log(err) + console.log(err); os.alert({ type: 'error', title: i18n.ts.loginFailed, diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue index 58c15d81b1..3f2af306e5 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="Math.random()" @submit.prevent="onSubmit"> +<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" :autocomplete="Math.random()" 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}$" :autocomplete="Math.random()" 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" :autocomplete="Math.random()" 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> @@ -34,7 +34,7 @@ <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> </template> </MkInput> - <MkInput v-model="password" class="_formBlock" type="password" :autocomplete="Math.random()" required data-cy-signup-password @update:modelValue="onChangePassword"> + <MkInput v-model="password" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword"> <template #label>{{ $ts.password }}</template> <template #prefix><i class="fas fa-lock"></i></template> <template #caption> @@ -43,7 +43,7 @@ <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span> </template> </MkInput> - <MkInput v-model="retypedPassword" class="_formBlock" type="password" :autocomplete="Math.random()" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype"> + <MkInput v-model="retypedPassword" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype"> <template #label>{{ $ts.password }} ({{ $ts.retype }})</template> <template #prefix><i class="fas fa-lock"></i></template> <template #caption> @@ -111,7 +111,7 @@ export default defineComponent({ ToSAgreement: false, hCaptchaResponse: null, reCaptchaResponse: null, - } + }; }, computed: { diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue index fe8f1c7cca..e6b20d9881 100644 --- a/packages/client/src/components/ui/button.vue +++ b/packages/client/src/components/ui/button.vue @@ -96,11 +96,11 @@ export default defineComponent({ } function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { - const origin = {x: circleCenterX, y: circleCenterY}; - const dist1 = distance({x: 0, y: 0}, origin); - const dist2 = distance({x: boxW, y: 0}, origin); - const dist3 = distance({x: 0, y: boxH}, origin); - const dist4 = distance({x: boxW, y: boxH }, origin); + const origin = { x: circleCenterX, y: circleCenterY }; + const dist1 = distance({ x: 0, y: 0 }, origin); + const dist2 = distance({ x: boxW, y: 0 }, origin); + const dist3 = distance({ x: 0, y: boxH }, origin); + const dist4 = distance({ x: boxW, y: boxH }, origin); return Math.max(dist1, dist2, dist3, dist4) * 2; } diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index ca56048262..dad5dfa8b0 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -1,5 +1,6 @@ <template> -<div ref="itemsEl" v-hotkey="keymap" +<div + ref="itemsEl" v-hotkey="keymap" class="rrevdjwt" :class="{ center: align === 'center', asDrawer }" :style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }" @@ -162,6 +163,15 @@ function focusDown() { position: relative; } + &:not(:disabled):hover { + color: var(--accent); + text-decoration: none; + + &:before { + background: var(--accentedBg); + } + } + &.danger { color: #ff2a2a; @@ -191,15 +201,6 @@ function focusDown() { } } - &:not(:disabled):hover { - color: var(--accent); - text-decoration: none; - - &:before { - background: var(--accentedBg); - } - } - &:not(:active):focus-visible { box-shadow: 0 0 0 2px var(--focus) inset; } diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue index 6de29c83fa..d2b2ccff7a 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/ui/modal-window.vue @@ -1,7 +1,7 @@ <template> -<MkModal ref="modal" :prefer-type="'dialog'" @click="$emit('click')" @closed="$emit('closed')"> - <div class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> - <div class="header"> +<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="headerEl" class="header"> <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <span class="title"> <slot name="header"></slot> @@ -11,82 +11,82 @@ </div> <div v-if="padding" class="body"> <div class="_section"> - <slot></slot> + <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> <div v-else class="body"> - <slot></slot> + <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> </MkModal> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted } from 'vue'; import MkModal from './modal.vue'; -export default defineComponent({ - components: { - MkModal - }, - props: { - withOkButton: { - type: Boolean, - required: false, - default: false - }, - okButtonDisabled: { - type: Boolean, - required: false, - default: false - }, - padding: { - type: Boolean, - required: false, - default: false - }, - width: { - type: Number, - required: false, - default: 400 - }, - height: { - type: Number, - required: false, - default: null - }, - canClose: { - type: Boolean, - required: false, - default: true, - }, - scroll: { - type: Boolean, - required: false, - default: true, - }, - }, +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, +}); - emits: ['click', 'close', 'closed', 'ok'], +const emit = defineEmits<{ + (event: 'click'): void; + (event: 'close'): void; + (event: 'closed'): void; + (event: 'ok'): void; +}>(); - data() { - return { - }; - }, +let modal = $ref<InstanceType<typeof MkModal>>(); +let rootEl = $ref<HTMLElement>(); +let headerEl = $ref<HTMLElement>(); +let bodyWidth = $ref(0); +let bodyHeight = $ref(0); - methods: { - close() { - this.$refs.modal.close(); - }, +const close = () => { + modal.close(); +}; - onKeydown(evt) { - if (evt.which === 27) { // Esc - evt.preventDefault(); - evt.stopPropagation(); - this.close(); - } - }, +const onBgClick = () => { + emit('click'); +}; + +const onKeydown = (evt) => { + if (evt.which === 27) { // Esc + evt.preventDefault(); + evt.stopPropagation(); + close(); } +}; + +const ro = new ResizeObserver((entries, observer) => { + bodyWidth = rootEl.offsetWidth; + bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; +}); + +onMounted(() => { + bodyWidth = rootEl.offsetWidth; + bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; + ro.observe(rootEl); +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +defineExpose({ + close, }); </script> diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue index 1e4159055e..d6a29ec4b7 100644 --- a/packages/client/src/components/ui/modal.vue +++ b/packages/client/src/components/ui/modal.vue @@ -1,5 +1,5 @@ <template> -<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="childRendered"> +<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened"> <div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> <div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div> <div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick"> @@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'opening'): void; + (ev: 'opened'): void; (ev: 'click'): void; (ev: 'esc'): void; (ev: 'close'): void; @@ -212,7 +213,9 @@ const align = () => { popover.style.top = top + 'px'; }; -const childRendered = () => { +const onOpened = () => { + emit('opened'); + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する const el = content.value!.children[0]; el.addEventListener('mousedown', ev => { @@ -234,10 +237,10 @@ onMounted(() => { } fixed.value = (type.value === 'drawer') || (getFixedContainer(props.src) != null); - await nextTick() + await nextTick(); align(); - }, { immediate: true, }); + }, { immediate: true }); nextTick(() => { const popover = content.value; diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue index ee1909554e..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; @@ -63,7 +67,7 @@ const setPosition = () => { } return [left, top]; - } + }; const calcPosWhenBottom = () => { let left: number; @@ -84,7 +88,7 @@ const setPosition = () => { } return [left, top]; - } + }; const calcPosWhenLeft = () => { let left: number; @@ -105,7 +109,7 @@ const setPosition = () => { } return [left, top]; - } + }; const calcPosWhenRight = () => { let left: number; @@ -126,7 +130,7 @@ const setPosition = () => { } return [left, top]; - } + }; const calc = (): { left: number; @@ -170,9 +174,7 @@ const setPosition = () => { return { left, top, transformOrigin: 'left center' }; } } - - return null as never; - } + }; const { left, top, transformOrigin } = calc(); el.value.style.transformOrigin = transformOrigin; diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue index 2066cf579d..3cd4378f03 100644 --- a/packages/client/src/components/ui/window.vue +++ b/packages/client/src/components/ui/window.vue @@ -4,14 +4,14 @@ <div class="body _window _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"> @@ -46,41 +46,41 @@ 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 + 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 +105,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 +172,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 +369,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> @@ -404,17 +417,25 @@ export default defineComponent({ border-bottom: solid 1px var(--divider); > .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; } diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue index c7bbd1fbd1..6c593c7b41 100644 --- a/packages/client/src/components/url-preview.vue +++ b/packages/client/src/components/url-preview.vue @@ -90,7 +90,7 @@ fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).the sitename = info.sitename; fetching = false; player = info.player; - }) + }); }); function adjustTweetHeight(message: any) { diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue index b6835795cb..74dd79f733 100644 --- a/packages/client/src/components/widgets.vue +++ b/packages/client/src/components/widgets.vue @@ -2,11 +2,11 @@ <div class="vjoppmmu"> <template v-if="edit"> <header> - <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)"> + <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select"> <template #label>{{ $ts.selectWidget }}</template> <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ $t(`_widgets.${widget}`) }}</option> </MkSelect> - <MkButton inline primary @click="addWidget"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + <MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton> </header> <XDraggable |