diff options
| author | tamaina <tamaina@hotmail.co.jp> | 2022-01-11 16:03:25 +0900 |
|---|---|---|
| committer | tamaina <tamaina@hotmail.co.jp> | 2022-01-11 16:03:25 +0900 |
| commit | 4219b4dd62aec5ed051a478d24eefb1cf65bded7 (patch) | |
| tree | 5c38c35d2c0e8b4d0abb7805360d90e8e3384da0 /packages/client/src/components | |
| parent | 初期値にデフォルト値を挿入する & JSON.parse/stringify操作を... (diff) | |
| parent | Merge branch 'develop' of https://github.com/misskey-dev/misskey into develop (diff) | |
| download | misskey-4219b4dd62aec5ed051a478d24eefb1cf65bded7.tar.gz misskey-4219b4dd62aec5ed051a478d24eefb1cf65bded7.tar.bz2 misskey-4219b4dd62aec5ed051a478d24eefb1cf65bded7.zip | |
Merge branch 'develop' into pizzax-indexeddb
Diffstat (limited to 'packages/client/src/components')
18 files changed, 1100 insertions, 1301 deletions
diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue index 6b07639f6d..cd04f62bca 100644 --- a/packages/client/src/components/abuse-report-window.vue +++ b/packages/client/src/components/abuse-report-window.vue @@ -1,8 +1,8 @@ <template> -<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')"> +<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> <template #header> <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i> - <I18n :src="$ts.reportAbuseOf" tag="span"> + <I18n :src="i18n.locale.reportAbuseOf" tag="span"> <template #name> <b><MkAcct :user="user"/></b> </template> @@ -11,65 +11,51 @@ <div class="dpvffvvy _monolithic_"> <div class="_section"> <MkTextarea v-model="comment"> - <template #label>{{ $ts.details }}</template> - <template #caption>{{ $ts.fillAbuseReportDescription }}</template> + <template #label>{{ i18n.locale.details }}</template> + <template #caption>{{ i18n.locale.fillAbuseReportDescription }}</template> </MkTextarea> </div> <div class="_section"> - <MkButton primary full :disabled="comment.length === 0" @click="send">{{ $ts.send }}</MkButton> + <MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.locale.send }}</MkButton> </div> </div> </XWindow> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; +<script setup lang="ts"> +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; import XWindow from '@/components/ui/window.vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XWindow, - MkTextarea, - MkButton, - }, +const props = defineProps<{ + user: Misskey.entities.User; + initialComment?: string; +}>(); - props: { - user: { - type: Object, - required: true, - }, - initialComment: { - type: String, - required: false, - }, - }, +const emit = defineEmits<{ + (e: 'closed'): void; +}>(); - emits: ['closed'], +const window = ref<InstanceType<typeof XWindow>>(); +const comment = ref(props.initialComment || ''); - data() { - return { - comment: this.initialComment || '', - }; - }, - - methods: { - send() { - os.apiWithDialog('users/report-abuse', { - userId: this.user.id, - comment: this.comment, - }, undefined, res => { - os.alert({ - type: 'success', - text: this.$ts.abuseReported - }); - this.$refs.window.close(); - }); - } - }, -}); +function send() { + os.apiWithDialog('users/report-abuse', { + userId: props.user.id, + comment: comment.value, + }, undefined).then(res => { + os.alert({ + type: 'success', + text: i18n.locale.abuseReported + }); + window.value?.close(); + emit('closed'); + }); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue index 450488b198..9ca511b6e9 100644 --- a/packages/client/src/components/analog-clock.vue +++ b/packages/client/src/components/analog-clock.vue @@ -40,106 +40,64 @@ </svg> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import * as tinycolor from 'tinycolor2'; -export default defineComponent({ - props: { - thickness: { - type: Number, - default: 0.1 - } - }, - - data() { - return { - now: new Date(), - enabled: true, - - graduationsPadding: 0.5, - handsPadding: 1, - handsTailLength: 0.7, - hHandLengthRatio: 0.75, - mHandLengthRatio: 1, - sHandLengthRatio: 1, - - computedStyle: getComputedStyle(document.documentElement) - }; - }, - - computed: { - dark(): boolean { - return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark(); - }, - - majorGraduationColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; - }, - minorGraduationColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - }, +withDefaults(defineProps<{ + thickness: number; +}>(), { + thickness: 0.1, +}); - sHandColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; - }, - mHandColor(): string { - return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString(); - }, - hHandColor(): string { - return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString(); - }, +const now = ref(new Date()); +const enabled = ref(true); +const graduationsPadding = ref(0.5); +const handsPadding = ref(1); +const handsTailLength = ref(0.7); +const hHandLengthRatio = ref(0.75); +const mHandLengthRatio = ref(1); +const sHandLengthRatio = ref(1); +const computedStyle = getComputedStyle(document.documentElement); - s(): number { - return this.now.getSeconds(); - }, - m(): number { - return this.now.getMinutes(); - }, - h(): number { - return this.now.getHours(); - }, +const dark = computed(() => tinycolor(computedStyle.getPropertyValue('--bg')).isDark()); +const majorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'); +const minorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'); +const sHandColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'); +const mHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--fg')).toHexString()); +const hHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--accent')).toHexString()); +const s = computed(() => now.value.getSeconds()); +const m = computed(() => now.value.getMinutes()); +const h = computed(() => now.value.getHours()); +const hAngle = computed(() => Math.PI * (h.value % 12 + (m.value + s.value / 60) / 60) / 6); +const mAngle = computed(() => Math.PI * (m.value + s.value / 60) / 30); +const sAngle = computed(() => Math.PI * s.value / 30); +const graduations = computed(() => { + const angles: number[] = []; + for (let i = 0; i < 60; i++) { + const angle = Math.PI * i / 30; + angles.push(angle); + } - hAngle(): number { - return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6; - }, - mAngle(): number { - return Math.PI * (this.m + this.s / 60) / 30; - }, - sAngle(): number { - return Math.PI * this.s / 30; - }, + return angles; +}); - graduations(): any { - const angles = []; - for (let i = 0; i < 60; i++) { - const angle = Math.PI * i / 30; - angles.push(angle); - } +function tick() { + now.value = new Date(); +} - return angles; +onMounted(() => { + const update = () => { + if (enabled.value) { + tick(); + setTimeout(update, 1000); } - }, - - mounted() { - const update = () => { - if (this.enabled) { - this.tick(); - setTimeout(update, 1000); - } - }; - update(); - }, - - beforeUnmount() { - this.enabled = false; - }, + }; + update(); +}); - methods: { - tick() { - this.now = new Date(); - } - } +onBeforeUnmount(() => { + enabled.value = false; }); </script> diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue index 30be2ac741..7ba83b7cb1 100644 --- a/packages/client/src/components/autocomplete.vue +++ b/packages/client/src/components/autocomplete.vue @@ -1,5 +1,5 @@ <template> -<div class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}"> +<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}"> <ol v-if="type === 'user'" ref="suggests" class="users"> <li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown"> <img class="avatar" :src="user.avatarUrl"/> @@ -8,7 +8,7 @@ </span> <span class="username">@{{ acct(user) }}</span> </li> - <li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ $ts.selectUser }}</li> + <li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.locale.selectUser }}</li> </ol> <ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags"> <li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown"> @@ -17,8 +17,8 @@ </ol> <ol v-else-if="emojis.length > 0" ref="suggests" class="emojis"> <li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> - <span v-if="emoji.isCustomEmoji" class="emoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> - <span v-else-if="!$store.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span> + <span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> + <span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span> <span v-else class="emoji">{{ emoji.emoji }}</span> <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> <span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span> @@ -33,15 +33,17 @@ </template> <script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import { emojilist } from '@/scripts/emojilist'; +import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import contains from '@/scripts/contains'; -import { twemojiSvgBase } from '@/scripts/twemoji-base'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { acct } from '@/filters/user'; import * as os from '@/os'; -import { instance } from '@/instance'; 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 = { emoji: string; @@ -54,16 +56,14 @@ 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)); + let codes = Array.from(char).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 codes.join('-'); + return codes.filter(x => x && x.length).join('-'); }; const emjdb: EmojiDef[] = lib.map(x => ({ emoji: x.char, name: x.name, - aliasOf: null, url: `${twemojiSvgBase}/${char2file(x.char)}.svg` })); @@ -112,291 +112,270 @@ emojiDefinitions.sort((a, b) => a.name.length - b.name.length); const emojiDb = markRaw(emojiDefinitions.concat(emjdb)); //#endregion -export default defineComponent({ - props: { - type: { - type: String, - required: true, - }, +export default { + emojiDb, + emojiDefinitions, + emojilist, + customEmojis, +}; +</script> - q: { - type: String, - required: false, - }, +<script lang="ts" setup> +const props = defineProps<{ + type: string; + q: string | null; + textarea: HTMLTextAreaElement; + close: () => void; + x: number; + y: number; +}>(); - textarea: { - type: HTMLTextAreaElement, - required: true, - }, +const emit = defineEmits<{ + (e: 'done', v: { type: string; value: any }): void; + (e: 'closed'): void; +}>(); - close: { - type: Function, - required: true, - }, +const suggests = ref<Element>(); +const rootEl = ref<HTMLDivElement>(); - x: { - type: Number, - required: true, - }, +const fetching = ref(true); +const users = ref<any[]>([]); +const hashtags = ref<any[]>([]); +const emojis = ref<(EmojiDef)[]>([]); +const items = ref<Element[] | HTMLCollection>([]); +const mfmTags = ref<string[]>([]); +const select = ref(-1); +const zIndex = os.claimZIndex('high'); - y: { - type: Number, - required: true, - }, - }, +function complete(type: string, value: any) { + emit('done', { type, value }); + emit('closed'); + if (type === 'emoji') { + let recents = defaultStore.state.recentlyUsedEmojis; + recents = recents.filter((e: any) => e !== value); + recents.unshift(value); + defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32)); + } +} - emits: ['done', 'closed'], +function setPosition() { + if (!rootEl.value) return; + if (props.x + rootEl.value.offsetWidth > window.innerWidth) { + rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px'; + } else { + rootEl.value.style.left = `${props.x}px`; + } + if (props.y + rootEl.value.offsetHeight > window.innerHeight) { + rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px'; + rootEl.value.style.marginTop = '0'; + } else { + rootEl.value.style.top = props.y + 'px'; + rootEl.value.style.marginTop = 'calc(1em + 8px)'; + } +} - data() { - return { - getStaticImageUrl, - fetching: true, - users: [], - hashtags: [], - emojis: [], - items: [], - mfmTags: [], - select: -1, - zIndex: os.claimZIndex('high'), +function exec() { + select.value = -1; + if (suggests.value) { + for (const el of Array.from(items.value)) { + el.removeAttribute('data-selected'); } - }, - - updated() { - this.setPosition(); - this.items = (this.$refs.suggests as Element | undefined)?.children || []; - }, - - mounted() { - this.setPosition(); - - this.textarea.addEventListener('keydown', this.onKeydown); - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); + } + if (props.type === 'user') { + if (!props.q) { + users.value = []; + fetching.value = false; + return; } - this.$nextTick(() => { - this.exec(); + const cacheKey = `autocomplete:user:${props.q}`; + const cache = sessionStorage.getItem(cacheKey); - this.$watch('q', () => { - this.$nextTick(() => { - this.exec(); - }); + if (cache) { + const users = JSON.parse(cache); + users.value = users; + fetching.value = false; + } else { + os.api('users/search-by-username-and-host', { + username: props.q, + limit: 10, + detail: false + }).then(searchedUsers => { + users.value = searchedUsers as any[]; + fetching.value = false; + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers)); }); - }); - }, - - beforeUnmount() { - this.textarea.removeEventListener('keydown', this.onKeydown); - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); } - }, - - methods: { - complete(type, value) { - this.$emit('done', { type, value }); - this.$emit('closed'); - - if (type === 'emoji') { - let recents = this.$store.state.recentlyUsedEmojis; - recents = recents.filter((e: any) => e !== value); - recents.unshift(value); - this.$store.set('recentlyUsedEmojis', recents.splice(0, 32)); - } - }, - - setPosition() { - if (this.x + this.$el.offsetWidth > window.innerWidth) { - this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px'; + } else if (props.type === 'hashtag') { + if (!props.q || props.q == '') { + hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]'); + fetching.value = false; + } else { + const cacheKey = `autocomplete:hashtag:${props.q}`; + const cache = sessionStorage.getItem(cacheKey); + if (cache) { + const hashtags = JSON.parse(cache); + hashtags.value = hashtags; + fetching.value = false; } else { - this.$el.style.left = this.x + 'px'; + os.api('hashtags/search', { + query: props.q, + limit: 30 + }).then(searchedHashtags => { + hashtags.value = searchedHashtags as any[]; + fetching.value = false; + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags)); + }); } + } + } else if (props.type === 'emoji') { + if (!props.q || props.q == '') { + // 最近使った絵文字をサジェスト + emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x) as EmojiDef[]; + return; + } - if (this.y + this.$el.offsetHeight > window.innerHeight) { - this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px'; - this.$el.style.marginTop = '0'; - } else { - this.$el.style.top = this.y + 'px'; - this.$el.style.marginTop = 'calc(1em + 8px)'; - } - }, + const matched: EmojiDef[] = []; + const max = 30; - exec() { - this.select = -1; - if (this.$refs.suggests) { - for (const el of Array.from(this.items)) { - el.removeAttribute('data-selected'); - } - } + emojiDb.some(x => { + if (x.name.startsWith(props.q || '') && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); - if (this.type === 'user') { - if (this.q == null) { - this.users = []; - this.fetching = false; - return; - } + if (matched.length < max) { + emojiDb.some(x => { + if (x.name.startsWith(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + } - const cacheKey = `autocomplete:user:${this.q}`; - const cache = sessionStorage.getItem(cacheKey); - if (cache) { - const users = JSON.parse(cache); - this.users = users; - this.fetching = false; - } else { - os.api('users/search-by-username-and-host', { - username: this.q, - limit: 10, - detail: false - }).then(users => { - this.users = users; - this.fetching = false; + if (matched.length < max) { + emojiDb.some(x => { + if (x.name.includes(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + } - // キャッシュ - sessionStorage.setItem(cacheKey, JSON.stringify(users)); - }); - } - } else if (this.type === 'hashtag') { - if (this.q == null || this.q == '') { - this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); - this.fetching = false; - } else { - const cacheKey = `autocomplete:hashtag:${this.q}`; - const cache = sessionStorage.getItem(cacheKey); - if (cache) { - const hashtags = JSON.parse(cache); - this.hashtags = hashtags; - this.fetching = false; - } else { - os.api('hashtags/search', { - query: this.q, - limit: 30 - }).then(hashtags => { - this.hashtags = hashtags; - this.fetching = false; + emojis.value = matched; + } else if (props.type === 'mfmTag') { + if (!props.q || props.q == '') { + mfmTags.value = MFM_TAGS; + return; + } - // キャッシュ - sessionStorage.setItem(cacheKey, JSON.stringify(hashtags)); - }); - } - } - } else if (this.type === 'emoji') { - if (this.q == null || this.q == '') { - // 最近使った絵文字をサジェスト - this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null); - return; - } + mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q || '')); + } +} + +function onMousedown(e: Event) { + if (!contains(rootEl.value, e.target) && (rootEl.value != e.target)) props.close(); +} - const matched = []; - const max = 30; +function onKeydown(e: KeyboardEvent) { + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; - emojiDb.some(x => { - if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x); - return matched.length == max; - }); - if (matched.length < max) { - emojiDb.some(x => { - if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); - return matched.length == max; - }); - } - if (matched.length < max) { - emojiDb.some(x => { - if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); - return matched.length == max; - }); - } + switch (e.key) { + case 'Enter': + if (select.value !== -1) { + cancel(); + (items.value[select.value] as any).click(); + } else { + props.close(); + } + break; - this.emojis = matched; - } else if (this.type === 'mfmTag') { - if (this.q == null || this.q == '') { - this.mfmTags = MFM_TAGS; - return; - } + case 'Escape': + cancel(); + props.close(); + break; - this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q)); + case 'ArrowUp': + if (select.value !== -1) { + cancel(); + selectPrev(); + } else { + props.close(); } - }, + break; + + case 'Tab': + case 'ArrowDown': + cancel(); + selectNext(); + break; - onMousedown(e) { - if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); - }, + default: + e.stopPropagation(); + props.textarea.focus(); + } +} - onKeydown(e) { - const cancel = () => { - e.preventDefault(); - e.stopPropagation(); - }; +function selectNext() { + if (++select.value >= items.value.length) select.value = 0; + if (items.value.length === 0) select.value = -1; + applySelect(); +} - switch (e.which) { - case 10: // [ENTER] - case 13: // [ENTER] - if (this.select !== -1) { - cancel(); - (this.items[this.select] as any).click(); - } else { - this.close(); - } - break; +function selectPrev() { + if (--select.value < 0) select.value = items.value.length - 1; + applySelect(); +} - case 27: // [ESC] - cancel(); - this.close(); - break; +function applySelect() { + for (const el of Array.from(items.value)) { + el.removeAttribute('data-selected'); + } - case 38: // [↑] - if (this.select !== -1) { - cancel(); - this.selectPrev(); - } else { - this.close(); - } - break; + if (select.value !== -1) { + items.value[select.value].setAttribute('data-selected', 'true'); + (items.value[select.value] as any).focus(); + } +} - case 9: // [TAB] - case 40: // [↓] - cancel(); - this.selectNext(); - break; +function chooseUser() { + props.close(); + os.selectUser().then(user => { + complete('user', user); + props.textarea.focus(); + }); +} - default: - e.stopPropagation(); - this.textarea.focus(); - } - }, +onUpdated(() => { + setPosition(); + items.value = suggests.value?.children || []; +}); - selectNext() { - if (++this.select >= this.items.length) this.select = 0; - if (this.items.length === 0) this.select = -1; - this.applySelect(); - }, +onMounted(() => { + setPosition(); - selectPrev() { - if (--this.select < 0) this.select = this.items.length - 1; - this.applySelect(); - }, + props.textarea.addEventListener('keydown', onKeydown); - applySelect() { - for (const el of Array.from(this.items)) { - el.removeAttribute('data-selected'); - } + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', onMousedown); + } - if (this.select !== -1) { - this.items[this.select].setAttribute('data-selected', 'true'); - (this.items[this.select] as any).focus(); - } - }, + nextTick(() => { + exec(); - chooseUser() { - this.close(); - os.selectUser().then(user => { - this.complete('user', user); - this.textarea.focus(); + watch(() => props.q, () => { + nextTick(() => { + exec(); }); - }, + }); + }); +}); + +onBeforeUnmount(() => { + props.textarea.removeEventListener('keydown', onKeydown); - acct + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', onMousedown); } }); </script> diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue index baa922506e..2a4181255f 100644 --- a/packages/client/src/components/captcha.vue +++ b/packages/client/src/components/captcha.vue @@ -1,12 +1,14 @@ <template> <div> - <span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span> - <div ref="captcha"></div> + <span v-if="!available">{{ i18n.locale.waiting }}<MkEllipsis/></span> + <div ref="captchaEl"></div> </div> </template> -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; +<script lang="ts" setup> +import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; type Captcha = { render(container: string | Node, options: { @@ -14,7 +16,7 @@ type Captcha = { }): string; remove(id: string): void; execute(id: string): void; - reset(id: string): void; + reset(id?: string): void; getResponse(id: string): string; }; @@ -29,95 +31,87 @@ declare global { } } -export default defineComponent({ - props: { - provider: { - type: String as PropType<CaptchaProvider>, - required: true, - }, - sitekey: { - type: String, - required: true, - }, - modelValue: { - type: String, - }, - }, +const props = defineProps<{ + provider: CaptchaProvider; + sitekey: string; + modelValue?: string | null; +}>(); - data() { - return { - available: false, - }; - }, +const emit = defineEmits<{ + (e: 'update:modelValue', v: string | null): void; +}>(); - computed: { - variable(): string { - switch (this.provider) { - case 'hcaptcha': return 'hcaptcha'; - case 'recaptcha': return 'grecaptcha'; - } - }, - loaded(): boolean { - return !!window[this.variable]; - }, - src(): string { - const endpoint = ({ - hcaptcha: 'https://hcaptcha.com/1', - recaptcha: 'https://www.recaptcha.net/recaptcha', - } as Record<CaptchaProvider, string>)[this.provider]; +const available = ref(false); - return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; - }, - captcha(): Captcha { - return window[this.variable] || {} as unknown as Captcha; - }, - }, +const captchaEl = ref<HTMLDivElement | undefined>(); - created() { - if (this.loaded) { - this.available = true; - } else { - (document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { - async: true, - id: this.provider, - src: this.src, - }))) - .addEventListener('load', () => this.available = true); - } - }, +const variable = computed(() => { + switch (props.provider) { + case 'hcaptcha': return 'hcaptcha'; + case 'recaptcha': return 'grecaptcha'; + } +}); + +const loaded = computed(() => !!window[variable.value]); + +const src = computed(() => { + const endpoint = ({ + hcaptcha: 'https://hcaptcha.com/1', + recaptcha: 'https://www.recaptcha.net/recaptcha', + } as Record<CaptchaProvider, string>)[props.provider]; + + return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; +}); + +const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); + +if (loaded.value) { + available.value = true; +} else { + (document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { + async: true, + id: props.provider, + src: src.value, + }))) + .addEventListener('load', () => available.value = true); +} + +function reset() { + if (captcha.value?.reset) captcha.value.reset(); +} + +function requestRender() { + if (captcha.value.render && captchaEl.value instanceof Element) { + captcha.value.render(captchaEl.value, { + sitekey: props.sitekey, + theme: defaultStore.state.darkMode ? 'dark' : 'light', + callback: callback, + 'expired-callback': callback, + 'error-callback': callback, + }); + } else { + setTimeout(requestRender, 1); + } +} + +function callback(response?: string) { + emit('update:modelValue', typeof response == 'string' ? response : null); +} - mounted() { - if (this.available) { - this.requestRender(); - } else { - this.$watch('available', this.requestRender); - } - }, +onMounted(() => { + if (available.value) { + requestRender(); + } else { + watch(available, requestRender); + } +}); - beforeUnmount() { - this.reset(); - }, +onBeforeUnmount(() => { + reset(); +}); - methods: { - reset() { - if (this.captcha?.reset) this.captcha.reset(); - }, - requestRender() { - if (this.captcha.render && this.$refs.captcha instanceof Element) { - this.captcha.render(this.$refs.captcha, { - sitekey: this.sitekey, - theme: this.$store.state.darkMode ? 'dark' : 'light', - callback: this.callback, - 'expired-callback': this.callback, - 'error-callback': this.callback, - }); - } else { - setTimeout(this.requestRender.bind(this), 1); - } - }, - callback(response?: string) { - this.$emit('update:modelValue', typeof response == 'string' ? response : null); - }, - }, +defineExpose({ + reset, }); + </script> diff --git a/packages/client/src/components/channel-follow-button.vue b/packages/client/src/components/channel-follow-button.vue index abde7c8148..0ad5384cd5 100644 --- a/packages/client/src/components/channel-follow-button.vue +++ b/packages/client/src/components/channel-follow-button.vue @@ -6,66 +6,54 @@ > <template v-if="!wait"> <template v-if="isFollowing"> - <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i> + <span v-if="full">{{ i18n.locale.unfollow }}</span><i class="fas fa-minus"></i> </template> <template v-else> - <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i> + <span v-if="full">{{ i18n.locale.follow }}</span><i class="fas fa-plus"></i> </template> </template> <template v-else> - <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> + <span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> </template> </button> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ref } from 'vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; -export default defineComponent({ - props: { - channel: { - type: Object, - required: true - }, - full: { - type: Boolean, - required: false, - default: false, - }, - }, +const props = withDefaults(defineProps<{ + channel: Record<string, any>; + full?: boolean; +}>(), { + full: false, +}); - data() { - return { - isFollowing: this.channel.isFollowing, - wait: false, - }; - }, +const isFollowing = ref<boolean>(props.channel.isFollowing); +const wait = ref(false); - methods: { - async onClick() { - this.wait = true; +async function onClick() { + wait.value = true; - try { - if (this.isFollowing) { - await os.api('channels/unfollow', { - channelId: this.channel.id - }); - this.isFollowing = false; - } else { - await os.api('channels/follow', { - channelId: this.channel.id - }); - this.isFollowing = true; - } - } catch (e) { - console.error(e); - } finally { - this.wait = false; - } + try { + if (isFollowing.value) { + await os.api('channels/unfollow', { + channelId: props.channel.id + }); + isFollowing.value = false; + } else { + await os.api('channels/follow', { + channelId: props.channel.id + }); + isFollowing.value = true; } + } catch (e) { + console.error(e); + } finally { + wait.value = false; } -}); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/channel-preview.vue b/packages/client/src/components/channel-preview.vue index f2b6de97fd..8d135a192f 100644 --- a/packages/client/src/components/channel-preview.vue +++ b/packages/client/src/components/channel-preview.vue @@ -6,7 +6,7 @@ <div class="status"> <div> <i class="fas fa-users fa-fw"></i> - <I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"> + <I18n :src="i18n.locale._channel.usersCount" tag="span" style="margin-left: 4px;"> <template #n> <b>{{ channel.usersCount }}</b> </template> @@ -14,7 +14,7 @@ </div> <div> <i class="fas fa-pencil-alt fa-fw"></i> - <I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"> + <I18n :src="i18n.locale._channel.notesCount" tag="span" style="margin-left: 4px;"> <template #n> <b>{{ channel.notesCount }}</b> </template> @@ -27,37 +27,26 @@ </article> <footer> <span v-if="channel.lastNotedAt"> - {{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> + {{ i18n.locale.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> </span> </footer> </MkA> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed } from 'vue'; +import { i18n } from '@/i18n'; -export default defineComponent({ - props: { - channel: { - type: Object, - required: true - }, - }, +const props = defineProps<{ + channel: Record<string, any>; +}>(); - data() { - return { - }; - }, - - computed: { - bannerStyle() { - if (this.channel.bannerUrl) { - return { backgroundImage: `url(${this.channel.bannerUrl})` }; - } else { - return { backgroundColor: '#4c5e6d' }; - } - } - }, +const bannerStyle = computed(() => { + if (props.channel.bannerUrl) { + return { backgroundImage: `url(${props.channel.bannerUrl})` }; + } else { + return { backgroundColor: '#4c5e6d' }; + } }); </script> diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue index b58484c2ac..45a38afe04 100644 --- a/packages/client/src/components/code-core.vue +++ b/packages/client/src/components/code-core.vue @@ -3,33 +3,17 @@ <pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed } from 'vue'; import 'prismjs'; import 'prismjs/themes/prism-okaidia.css'; -export default defineComponent({ - props: { - code: { - type: String, - required: true - }, - lang: { - type: String, - required: false - }, - inline: { - type: Boolean, - required: false - } - }, - computed: { - prismLang() { - return Prism.languages[this.lang] ? this.lang : 'js'; - }, - html() { - return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang); - } - } -}); +const props = defineProps<{ + code: string; + lang?: string; + inline?: boolean; +}>(); + +const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js'); +const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value)); </script> diff --git a/packages/client/src/components/code.vue b/packages/client/src/components/code.vue index f5d6c5673a..d6478fd2f8 100644 --- a/packages/client/src/components/code.vue +++ b/packages/client/src/components/code.vue @@ -2,26 +2,14 @@ <XCode :code="code" :lang="lang" :inline="inline"/> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; -export default defineComponent({ - components: { - XCode: defineAsyncComponent(() => import('./code-core.vue')) - }, - props: { - code: { - type: String, - required: true - }, - lang: { - type: String, - required: false - }, - inline: { - type: Boolean, - required: false - } - } -}); +defineProps<{ + code: string; + lang?: string; + inline?: boolean; +}>(); + +const XCode = defineAsyncComponent(() => import('./code-core.vue')); </script> diff --git a/packages/client/src/components/cw-button.vue b/packages/client/src/components/cw-button.vue index b0a9860de2..ccfd11462a 100644 --- a/packages/client/src/components/cw-button.vue +++ b/packages/client/src/components/cw-button.vue @@ -1,6 +1,6 @@ <template> <button class="nrvgflfu _button" @click="toggle"> - <b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b> + <b>{{ modelValue ? i18n.locale._cw.hide : i18n.locale._cw.show }}</b> <span v-if="!modelValue">{{ label }}</span> </button> </template> diff --git a/packages/client/src/components/date-separated-list.vue b/packages/client/src/components/date-separated-list.vue index aa84c6f60d..c85a0a6ffc 100644 --- a/packages/client/src/components/date-separated-list.vue +++ b/packages/client/src/components/date-separated-list.vue @@ -1,6 +1,8 @@ <script lang="ts"> import { defineComponent, h, PropType, TransitionGroup } from 'vue'; import MkAd from '@/components/global/ad.vue'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; export default defineComponent({ props: { @@ -30,29 +32,29 @@ export default defineComponent({ }, }, - methods: { - getDateText(time: string) { + setup(props, { slots, expose }) { + function getDateText(time: string) { const date = new Date(time).getDate(); const month = new Date(time).getMonth() + 1; - return this.$t('monthAndDay', { + return i18n.t('monthAndDay', { month: month.toString(), day: date.toString() }); } - }, - render() { - if (this.items.length === 0) return; + if (props.items.length === 0) return; + + const renderChildren = () => props.items.map((item, i) => { + if (!slots || !slots.default) return; - const renderChildren = () => this.items.map((item, i) => { - const el = this.$slots.default({ + const el = slots.default({ item: item })[0]; if (el.key == null && item.id) el.key = item.id; if ( - i != this.items.length - 1 && - new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() + i != props.items.length - 1 && + new Date(item.createdAt).getDate() != new Date(props.items[i + 1].createdAt).getDate() ) { const separator = h('div', { class: 'separator', @@ -64,10 +66,10 @@ export default defineComponent({ h('i', { class: 'fas fa-angle-up icon', }), - this.getDateText(item.createdAt) + getDateText(item.createdAt) ]), h('span', [ - this.getDateText(this.items[i + 1].createdAt), + getDateText(props.items[i + 1].createdAt), h('i', { class: 'fas fa-angle-down icon', }) @@ -76,7 +78,7 @@ export default defineComponent({ return [el, separator]; } else { - if (this.ad && item._shouldInsertAd_) { + if (props.ad && item._shouldInsertAd_) { return [h(MkAd, { class: 'a', // advertiseの意(ブロッカー対策) key: item.id + ':ad', @@ -88,18 +90,19 @@ export default defineComponent({ } }); - return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? { - class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''), - name: 'list', - tag: 'div', - 'data-direction': this.direction, - 'data-reversed': this.reversed ? 'true' : 'false', - } : { - class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''), - }, { - default: renderChildren - }); - }, + return () => h( + defaultStore.state.animation ? TransitionGroup : 'div', + defaultStore.state.animation ? { + class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), + name: 'list', + tag: 'div', + 'data-direction': props.direction, + 'data-reversed': props.reversed ? 'true' : 'false', + } : { + class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''), + }, + { default: renderChildren }); + } }); </script> diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue index c2fa1b02b8..9cd5234684 100644 --- a/packages/client/src/components/dialog.vue +++ b/packages/client/src/components/dialog.vue @@ -14,7 +14,7 @@ </div> <header v-if="title"><Mfm :text="title"/></header> <div v-if="text" class="body"><Mfm :text="text"/></div> - <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"> + <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="fas fa-lock"></i></template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> @@ -38,118 +38,107 @@ </MkModal> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, ref } from 'vue'; import MkModal from '@/components/ui/modal.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; -export default defineComponent({ - components: { - MkModal, - MkButton, - MkInput, - MkSelect, - }, +type Input = { + type: HTMLInputElement['type']; + placeholder?: string | null; + default: any | null; +}; - props: { - type: { - type: String, - required: false, - default: 'info' - }, - title: { - type: String, - required: false - }, - text: { - type: String, - required: false - }, - input: { - required: false - }, - select: { - required: false - }, - icon: { - required: false - }, - actions: { - required: false - }, - showOkButton: { - type: Boolean, - default: true - }, - showCancelButton: { - type: Boolean, - default: false - }, - cancelableByBgClick: { - type: Boolean, - default: true - }, - }, +type Select = { + items: { + value: string; + text: string; + }[]; + groupedItems: { + label: string; + items: { + value: string; + text: string; + }[]; + }[]; + default: string | null; +}; - emits: ['done', 'closed'], +const props = withDefaults(defineProps<{ + type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting'; + title: string; + text?: string; + input?: Input; + select?: Select; + icon?: string; + actions?: { + text: string; + primary?: boolean, + callback: (...args: any[]) => void; + }[]; + showOkButton?: boolean; + showCancelButton?: boolean; + cancelableByBgClick?: boolean; +}>(), { + type: 'info', + showOkButton: true, + showCancelButton: false, + cancelableByBgClick: true, +}); - data() { - return { - inputValue: this.input && this.input.default ? this.input.default : null, - selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null, - }; - }, +const emit = defineEmits<{ + (e: 'done', v: { canceled: boolean; result: any }): void; + (e: 'closed'): void; +}>(); - mounted() { - document.addEventListener('keydown', this.onKeydown); - }, +const modal = ref<InstanceType<typeof MkModal>>(); - beforeUnmount() { - document.removeEventListener('keydown', this.onKeydown); - }, +const inputValue = ref(props.input?.default || null); +const selectedValue = ref(props.select?.default || null); - methods: { - done(canceled, result?) { - this.$emit('done', { canceled, result }); - this.$refs.modal.close(); - }, +function done(canceled: boolean, result?) { + emit('done', { canceled, result }); + modal.value?.close(); +} - async ok() { - if (!this.showOkButton) return; +async function ok() { + if (!props.showOkButton) return; - const result = - this.input ? this.inputValue : - this.select ? this.selectedValue : - true; - this.done(false, result); - }, + const result = + props.input ? inputValue.value : + props.select ? selectedValue.value : + true; + done(false, result); +} - cancel() { - this.done(true); - }, +function cancel() { + done(true); +} +/* +function onBgClick() { + if (props.cancelableByBgClick) cancel(); +} +*/ +function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') cancel(); +} - onBgClick() { - if (this.cancelableByBgClick) { - this.cancel(); - } - }, +function onInputKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + ok(); + } +} - onKeydown(e) { - if (e.which === 27) { // ESC - this.cancel(); - } - }, +onMounted(() => { + document.addEventListener('keydown', onKeydown); +}); - onInputKeydown(e) { - if (e.which === 13) { // Enter - e.preventDefault(); - e.stopPropagation(); - this.ok(); - } - } - } +onBeforeUnmount(() => { + document.removeEventListener('keydown', onKeydown); }); </script> diff --git a/packages/client/src/components/form/pagination.vue b/packages/client/src/components/form/pagination.vue deleted file mode 100644 index 3d3b40a783..0000000000 --- a/packages/client/src/components/form/pagination.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> -<FormSlot> - <template #label><slot name="label"></slot></template> - <div class="abcaccfa"> - <slot :items="items"></slot> - <div v-if="empty" key="_empty_" class="empty"> - <slot name="empty"></slot> - </div> - <MkButton v-show="more" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> -</FormSlot> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@/components/ui/button.vue'; -import FormSlot from './slot.vue'; -import paging from '@/scripts/paging'; - -export default defineComponent({ - components: { - MkButton, - FormSlot, - }, - - mixins: [ - paging({}), - ], - - props: { - pagination: { - required: true - }, - }, -}); -</script> - -<style lang="scss" scoped> -.abcaccfa { -} -</style> diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue index 4136f72b1b..d6107216e2 100644 --- a/packages/client/src/components/notes.vue +++ b/packages/client/src/components/notes.vue @@ -1,114 +1,48 @@ <template> -<transition name="fade" mode="out-in"> - <MkLoading v-if="fetching"/> - - <MkError v-else-if="error" @retry="init()"/> - - <div v-else-if="empty" class="_fullinfo"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noNotes }}</div> - </div> - - <div v-else class="giivymft" :class="{ noGap }"> - <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <MkButton style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreFeature"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> +<MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noNotes }}</div> </div> + </template> - <XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes"> - <XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/> - </XList> - - <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <MkButton v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> + <template #default="{ items: notes }"> + <div class="giivymft" :class="{ noGap }"> + <XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes"> + <XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/> + </XList> </div> - </div> -</transition> + </template> +</MkPagination> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import paging from '@/scripts/paging'; -import XNote from './note.vue'; -import XList from './date-separated-list.vue'; -import MkButton from '@/components/ui/button.vue'; +<script lang="ts" setup> +import { ref } from 'vue'; +import XNote from '@/components/note.vue'; +import XList from '@/components/date-separated-list.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { Paging } from '@/components/ui/pagination.vue'; -export default defineComponent({ - components: { - XNote, XList, MkButton, - }, +const props = defineProps<{ + pagination: Paging; + noGap?: boolean; +}>(); - mixins: [ - paging({ - before: (self) => { - self.$emit('before'); - }, +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); - after: (self, e) => { - self.$emit('after', e); - } - }), - ], +const updated = (oldValue, newValue) => { + pagingComponent.value?.updateItem(oldValue.id, () => newValue); +}; - props: { - pagination: { - required: true - }, - prop: { - type: String, - required: false - }, - noGap: { - type: Boolean, - required: false, - default: false - }, +defineExpose({ + prepend: (note) => { + pagingComponent.value?.prepend(note); }, - - emits: ['before', 'after'], - - computed: { - notes(): any[] { - return this.prop ? this.items.map(item => item[this.prop]) : this.items; - }, - - reversed(): boolean { - return this.pagination.reversed; - } - }, - - methods: { - updated(oldValue, newValue) { - const i = this.notes.findIndex(n => n === oldValue); - if (this.prop) { - this.items[i][this.prop] = newValue; - } else { - this.items[i] = newValue; - } - }, - - focus() { - this.$refs.notes.focus(); - } - } }); </script> <style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} - .giivymft { &.noGap { > .notes { diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue index 328888c355..31511fb515 100644 --- a/packages/client/src/components/notifications.vue +++ b/packages/client/src/components/notifications.vue @@ -1,159 +1,84 @@ <template> -<transition name="fade" mode="out-in"> - <MkLoading v-if="fetching"/> +<MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noNotifications }}</div> + </div> + </template> - <MkError v-else-if="error" @retry="init()"/> - - <p v-else-if="empty" class="mfcuwfyp">{{ $ts.noNotifications }}</p> - - <div v-else> - <XList v-slot="{ item: notification }" class="elsfgstc" :items="items" :no-gap="true"> - <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification.note, $event)"/> + <template #default="{ items: notifications }"> + <XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true"> + <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification, $event)"/> <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> </XList> - - <MkButton v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" primary style="margin: var(--margin) auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> -</transition> + </template> +</MkPagination> </template> -<script lang="ts"> -import { defineComponent, PropType, markRaw } from 'vue'; -import paging from '@/scripts/paging'; -import XNotification from './notification.vue'; -import XList from './date-separated-list.vue'; -import XNote from './note.vue'; +<script lang="ts" setup> +import { defineComponent, PropType, 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 XNotification from '@/components/notification.vue'; +import XList from '@/components/date-separated-list.vue'; +import XNote from '@/components/note.vue'; import * as os from '@/os'; import { stream } from '@/stream'; -import MkButton from '@/components/ui/button.vue'; - -export default defineComponent({ - components: { - XNotification, - XList, - XNote, - MkButton, - }, +import { $i } from '@/account'; - mixins: [ - paging({}), - ], +const props = defineProps<{ + includeTypes?: PropType<typeof notificationTypes[number][]>; + unreadOnly?: boolean; +}>(); - props: { - includeTypes: { - type: Array as PropType<typeof notificationTypes[number][]>, - required: false, - default: null, - }, - unreadOnly: { - type: Boolean, - required: false, - default: false, - }, - }, +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); - data() { - return { - connection: null, - pagination: { - endpoint: 'i/notifications', - limit: 10, - params: () => ({ - includeTypes: this.allIncludeTypes || undefined, - unreadOnly: this.unreadOnly, - }) - }, - }; - }, +const allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x))); - computed: { - allIncludeTypes() { - return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x)); - } - }, +const pagination: Paging = { + endpoint: 'i/notifications' as const, + limit: 10, + params: computed(() => ({ + includeTypes: allIncludeTypes.value || undefined, + unreadOnly: props.unreadOnly, + })), +}; - watch: { - includeTypes: { - handler() { - this.reload(); - }, - deep: true - }, - unreadOnly: { - handler() { - this.reload(); - }, - }, - // TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、 - // mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す - '$i.mutingNotificationTypes': { - handler() { - if (this.includeTypes === null) { - this.reload(); - } - }, - deep: true - } - }, - - mounted() { - this.connection = markRaw(stream.useChannel('main')); - this.connection.on('notification', this.onNotification); - }, - - beforeUnmount() { - this.connection.dispose(); - }, +const onNotification = (notification) => { + const isMuted = !allIncludeTypes.value.includes(notification.type); + if (isMuted || document.visibilityState === 'visible') { + stream.send('readNotification', { + id: notification.id + }); + } - methods: { - onNotification(notification) { - const isMuted = !this.allIncludeTypes.includes(notification.type); - if (isMuted || document.visibilityState === 'visible') { - stream.send('readNotification', { - id: notification.id - }); - } + if (!isMuted) { + pagingComponent.value.prepend({ + ...notification, + isRead: document.visibilityState === 'visible' + }); + } +}; - if (!isMuted) { - this.prepend({ - ...notification, - isRead: document.visibilityState === 'visible' - }); - } - }, +const noteUpdated = (item, note) => { + pagingComponent.value?.updateItem(item.id, old => ({ + ...old, + note: note, + })); +}; - noteUpdated(oldValue, newValue) { - const i = this.items.findIndex(n => n.note === oldValue); - this.items[i] = { - ...this.items[i], - note: newValue - }; - }, - } +onMounted(() => { + const connection = stream.useChannel('main'); + connection.on('notification', onNotification); + onUnmounted(() => { + connection.dispose(); + }); }); </script> <style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} - -.mfcuwfyp { - margin: 0; - padding: 16px; - text-align: center; - color: var(--fg); -} - .elsfgstc { background: var(--panel); } diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/timeline.vue index 53697671b2..a7af02c30b 100644 --- a/packages/client/src/components/timeline.vue +++ b/packages/client/src/components/timeline.vue @@ -1,184 +1,143 @@ <template> -<XNotes ref="tl" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> +<XNotes ref="tlComponent" :no-gap="!$store.state.showGapBetweenNotesInTimeline" :pagination="pagination" @queue="emit('queue', $event)"/> </template> -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; +<script lang="ts" setup> +import { ref, computed, provide, onUnmounted } from 'vue'; import XNotes from './notes.vue'; import * as os from '@/os'; import { stream } from '@/stream'; import * as sound from '@/scripts/sound'; +import { $i } from '@/account'; -export default defineComponent({ - components: { - XNotes - }, +const props = defineProps<{ + src: string; + list?: string; + antenna?: string; + channel?: string; + sound?: boolean; +}>(); - provide() { - return { - inChannel: this.src === 'channel' - }; - }, +const emit = defineEmits<{ + (e: 'note'): void; + (e: 'queue', count: number): void; +}>(); - props: { - src: { - type: String, - required: true - }, - list: { - type: String, - required: false - }, - antenna: { - type: String, - required: false - }, - channel: { - type: String, - required: false - }, - sound: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['note', 'queue', 'before', 'after'], +provide('inChannel', computed(() => props.src === 'channel')); - data() { - return { - connection: null, - connection2: null, - pagination: null, - baseQuery: { - includeMyRenotes: this.$store.state.showMyRenotes, - includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.showLocalRenotes - }, - query: {}, - date: null - }; - }, +const tlComponent = ref<InstanceType<typeof XNotes>>(); - created() { - const prepend = note => { - (this.$refs.tl as any).prepend(note); +const prepend = note => { + tlComponent.value.prepend(note); - this.$emit('note'); + emit('note'); - if (this.sound) { - sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); - } - }; + if (props.sound) { + sound.play($i && (note.userId === $i.id) ? 'noteMy' : 'note'); + } +}; - const onUserAdded = () => { - (this.$refs.tl as any).reload(); - }; +const onUserAdded = () => { + tlComponent.value.reload(); +}; - const onUserRemoved = () => { - (this.$refs.tl as any).reload(); - }; +const onUserRemoved = () => { + tlComponent.value.reload(); +}; - const onChangeFollowing = () => { - if (!this.$refs.tl.backed) { - this.$refs.tl.reload(); - } - }; +const onChangeFollowing = () => { + if (!tlComponent.value.backed) { + tlComponent.value.reload(); + } +}; - let endpoint; +let endpoint; +let query; +let connection; +let connection2; - if (this.src == 'antenna') { - endpoint = 'antennas/notes'; - this.query = { - antennaId: this.antenna - }; - this.connection = markRaw(stream.useChannel('antenna', { - antennaId: this.antenna - })); - this.connection.on('note', prepend); - } else if (this.src == 'home') { - endpoint = 'notes/timeline'; - this.connection = markRaw(stream.useChannel('homeTimeline')); - this.connection.on('note', prepend); +if (props.src === 'antenna') { + endpoint = 'antennas/notes'; + query = { + antennaId: props.antenna + }; + connection = stream.useChannel('antenna', { + antennaId: props.antenna + }); + connection.on('note', prepend); +} else if (props.src === 'home') { + endpoint = 'notes/timeline'; + connection = stream.useChannel('homeTimeline'); + connection.on('note', prepend); - this.connection2 = markRaw(stream.useChannel('main')); - this.connection2.on('follow', onChangeFollowing); - this.connection2.on('unfollow', onChangeFollowing); - } else if (this.src == 'local') { - endpoint = 'notes/local-timeline'; - this.connection = markRaw(stream.useChannel('localTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'social') { - endpoint = 'notes/hybrid-timeline'; - this.connection = markRaw(stream.useChannel('hybridTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'global') { - endpoint = 'notes/global-timeline'; - this.connection = markRaw(stream.useChannel('globalTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'mentions') { - endpoint = 'notes/mentions'; - this.connection = markRaw(stream.useChannel('main')); - this.connection.on('mention', prepend); - } else if (this.src == 'directs') { - endpoint = 'notes/mentions'; - this.query = { - visibility: 'specified' - }; - const onNote = note => { - if (note.visibility == 'specified') { - prepend(note); - } - }; - this.connection = markRaw(stream.useChannel('main')); - this.connection.on('mention', onNote); - } else if (this.src == 'list') { - endpoint = 'notes/user-list-timeline'; - this.query = { - listId: this.list - }; - this.connection = markRaw(stream.useChannel('userList', { - listId: this.list - })); - this.connection.on('note', prepend); - this.connection.on('userAdded', onUserAdded); - this.connection.on('userRemoved', onUserRemoved); - } else if (this.src == 'channel') { - endpoint = 'channels/timeline'; - this.query = { - channelId: this.channel - }; - this.connection = markRaw(stream.useChannel('channel', { - channelId: this.channel - })); - this.connection.on('note', prepend); + connection2 = stream.useChannel('main'); + connection2.on('follow', onChangeFollowing); + connection2.on('unfollow', onChangeFollowing); +} else if (props.src === 'local') { + endpoint = 'notes/local-timeline'; + connection = stream.useChannel('localTimeline'); + connection.on('note', prepend); +} else if (props.src === 'social') { + endpoint = 'notes/hybrid-timeline'; + connection = stream.useChannel('hybridTimeline'); + connection.on('note', prepend); +} else if (props.src === 'global') { + endpoint = 'notes/global-timeline'; + connection = stream.useChannel('globalTimeline'); + connection.on('note', prepend); +} else if (props.src === 'mentions') { + endpoint = 'notes/mentions'; + connection = stream.useChannel('main'); + connection.on('mention', prepend); +} else if (props.src === 'directs') { + endpoint = 'notes/mentions'; + query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); } + }; + connection = stream.useChannel('main'); + connection.on('mention', onNote); +} else if (props.src === 'list') { + endpoint = 'notes/user-list-timeline'; + query = { + listId: props.list + }; + connection = stream.useChannel('userList', { + listId: props.list + }); + connection.on('note', prepend); + connection.on('userAdded', onUserAdded); + connection.on('userRemoved', onUserRemoved); +} else if (props.src === 'channel') { + endpoint = 'channels/timeline'; + query = { + channelId: props.channel + }; + connection = stream.useChannel('channel', { + channelId: props.channel + }); + connection.on('note', prepend); +} - this.pagination = { - endpoint: endpoint, - limit: 10, - params: init => ({ - untilDate: this.date?.getTime(), - ...this.baseQuery, ...this.query - }) - }; - }, - - beforeUnmount() { - this.connection.dispose(); - if (this.connection2) this.connection2.dispose(); - }, +const pagination = { + endpoint: endpoint, + limit: 10, + params: query, +}; - methods: { - focus() { - this.$refs.tl.focus(); - }, - - timetravel(date?: Date) { - this.date = date; - this.$refs.tl.reload(); - } - } +onUnmounted(() => { + connection.dispose(); + if (connection2) connection2.dispose(); }); + +/* TODO +const timetravel = (date?: Date) => { + this.date = date; + this.$refs.tl.reload(); +}; +*/ </script> diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue index 64af4a54f7..d4451e27cb 100644 --- a/packages/client/src/components/ui/pagination.vue +++ b/packages/client/src/components/ui/pagination.vue @@ -13,43 +13,267 @@ </slot> </div> - <div v-else class="cxiknjgy"> + <div v-else ref="rootEl"> <slot :items="items"></slot> - <div v-show="more" key="_more_" class="more _gap"> - <MkButton v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> + <div v-show="more" key="_more_" class="cxiknjgy _gap"> + <MkButton v-if="!moreFetching" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore"> + {{ $ts.loadMore }} </MkButton> + <MkLoading v-else class="loading"/> </div> </div> </transition> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from './button.vue'; -import paging from '@/scripts/paging'; +<script lang="ts" setup> +import { computed, ComputedRef, isRef, markRaw, onActivated, onDeactivated, Ref, ref, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import * as os from '@/os'; +import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; +import MkButton from '@/components/ui/button.vue'; -export default defineComponent({ - components: { - MkButton - }, +const SECOND_FETCH_LIMIT = 30; - mixins: [ - paging({}), - ], +export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = { + endpoint: E; + limit: number; + params?: misskey.Endpoints[E]['req'] | ComputedRef<misskey.Endpoints[E]['req']>; - props: { - pagination: { - required: true - }, + /** + * 検索APIのような、ページング不可なエンドポイントを利用する場合 + * (そのようなAPIをこの関数で使うのは若干矛盾してるけど) + */ + noPaging?: boolean; - disableAutoLoad: { - type: Boolean, - required: false, - default: false, + /** + * items 配列の中身を逆順にする(新しい方が最後) + */ + reversed?: boolean; + + offsetMode?: boolean; +}; + +const props = withDefaults(defineProps<{ + pagination: Paging; + disableAutoLoad?: boolean; + displayLimit?: number; +}>(), { + displayLimit: 30, +}); + +const emit = defineEmits<{ + (e: 'queue', count: number): void; +}>(); + +type Item = { id: string; [another: string]: unknown; }; + +const rootEl = ref<HTMLElement>(); +const items = ref<Item[]>([]); +const queue = ref<Item[]>([]); +const offset = ref(0); +const fetching = ref(true); +const moreFetching = ref(false); +const inited = ref(false); +const more = ref(false); +const backed = ref(false); // 遡り中か否か +const isBackTop = ref(false); +const empty = computed(() => items.value.length === 0 && !fetching.value && inited.value); +const error = computed(() => !fetching.value && !inited.value); + +const init = async (): Promise<void> => { + queue.value = []; + fetching.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await os.api(props.pagination.endpoint, { + ...params, + limit: props.pagination.noPaging ? (props.pagination.limit || 10) : (props.pagination.limit || 10) + 1, + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + markRaw(item); + if (props.pagination.reversed) { + if (i === res.length - 2) item._shouldInsertAd_ = true; + } else { + if (i === 3) item._shouldInsertAd_ = true; + } + } + if (!props.pagination.noPaging && (res.length > (props.pagination.limit || 10))) { + res.pop(); + items.value = props.pagination.reversed ? [...res].reverse() : res; + more.value = true; + } else { + items.value = props.pagination.reversed ? [...res].reverse() : res; + more.value = false; + } + offset.value = res.length; + inited.value = true; + fetching.value = false; + }, e => { + fetching.value = false; + }); +}; + +const reload = (): void => { + items.value = []; + init(); +}; + +const fetchMore = async (): Promise<void> => { + if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + moreFetching.value = true; + backed.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await os.api(props.pagination.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(props.pagination.offsetMode ? { + offset: offset.value, + } : { + untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + }), + }).then(res => { + for (let i = 0; i < res.length; i++) { + const item = res[i]; + markRaw(item); + if (props.pagination.reversed) { + if (i === res.length - 9) item._shouldInsertAd_ = true; + } else { + if (i === 10) item._shouldInsertAd_ = true; + } + } + if (res.length > SECOND_FETCH_LIMIT) { + res.pop(); + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = true; + } else { + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = false; + } + offset.value += res.length; + moreFetching.value = false; + }, e => { + moreFetching.value = false; + }); +}; + +const fetchMoreAhead = async (): Promise<void> => { + if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return; + moreFetching.value = true; + const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {}; + await os.api(props.pagination.endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(props.pagination.offsetMode ? { + offset: offset.value, + } : { + sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + }), + }).then(res => { + for (const item of res) { + markRaw(item); + } + if (res.length > SECOND_FETCH_LIMIT) { + res.pop(); + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = true; + } else { + items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); + more.value = false; + } + offset.value += res.length; + moreFetching.value = false; + }, e => { + moreFetching.value = false; + }); +}; + +const prepend = (item: Item): void => { + if (rootEl.value == null) return; + + if (props.pagination.reversed) { + const container = getScrollContainer(rootEl.value); + if (container == null) return; // TODO? + + const pos = getScrollPosition(rootEl.value); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + const isBottom = (pos + viewHeight > height - 32); + if (isBottom) { + // オーバーフローしたら古いアイテムは捨てる + if (items.value.length >= props.displayLimit) { + // このやり方だとVue 3.2以降アニメーションが動かなくなる + //items.value = items.value.slice(-props.displayLimit); + while (items.value.length >= props.displayLimit) { + items.value.shift(); + } + more.value = true; + } + } + items.value.push(item); + // TODO + } else { + const isTop = isBackTop.value || (document.body.contains(rootEl.value) && isTopVisible(rootEl.value)); + + if (isTop) { + // Prepend the item + items.value.unshift(item); + + // オーバーフローしたら古いアイテムは捨てる + if (items.value.length >= props.displayLimit) { + // このやり方だとVue 3.2以降アニメーションが動かなくなる + //this.items = items.value.slice(0, props.displayLimit); + while (items.value.length >= props.displayLimit) { + items.value.pop(); + } + more.value = true; + } + } else { + queue.value.push(item); + onScrollTop(rootEl.value, () => { + for (const item of queue.value) { + prepend(item); + } + queue.value = []; + }); } - }, + } +}; + +const append = (item: Item): void => { + items.value.push(item); +}; + +const updateItem = (id: Item['id'], replacer: (old: Item) => Item): void => { + const i = items.value.findIndex(item => item.id === id); + items.value[i] = replacer(items.value[i]); +}; + +if (props.pagination.params && isRef(props.pagination.params)) { + watch(props.pagination.params, init, { deep: true }); +} + +watch(queue, (a, b) => { + if (a.length === 0 && b.length === 0) return; + emit('queue', queue.value.length); +}, { deep: true }); + +init(); + +onActivated(() => { + isBackTop.value = false; +}); + +onDeactivated(() => { + isBackTop.value = window.scrollY === 0; +}); + +defineExpose({ + items, + reload, + fetchMoreAhead, + prepend, + append, + updateItem, }); </script> @@ -64,11 +288,9 @@ export default defineComponent({ } .cxiknjgy { - > .more > .button { + > .button { margin-left: auto; margin-right: auto; - height: 48px; - min-width: 150px; } } </style> diff --git a/packages/client/src/components/user-list.vue b/packages/client/src/components/user-list.vue index 2148dab608..3e273721c7 100644 --- a/packages/client/src/components/user-list.vue +++ b/packages/client/src/components/user-list.vue @@ -1,91 +1,39 @@ <template> -<MkError v-if="error" @retry="init()"/> +<MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noUsers }}</div> + </div> + </template> -<div v-else class="efvhhmdq _isolated"> - <div v-if="empty" class="no-users"> - <p>{{ $ts.noUsers }}</p> - </div> - <div class="users"> - <MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/> - </div> - <button v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" class="more" :class="{ fetching: moreFetching }" :disabled="moreFetching" @click="fetchMore"> - <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }} - </button> -</div> + <template #default="{ items: users }"> + <div class="efvhhmdq"> + <MkUserInfo v-for="user in users" :key="user.id" class="user" :user="user"/> + </div> + </template> +</MkPagination> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -import paging from '@/scripts/paging'; -import MkUserInfo from './user-info.vue'; +<script lang="ts" setup> +import { ref } from 'vue'; +import MkUserInfo from '@/components/user-info.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { Paging } from '@/components/ui/pagination.vue'; import { userPage } from '@/filters/user'; -export default defineComponent({ - components: { - MkUserInfo, - }, +const props = defineProps<{ + pagination: Paging; + noGap?: boolean; +}>(); - mixins: [ - paging({}), - ], - - props: { - pagination: { - required: true - }, - extract: { - required: false - }, - expanded: { - type: Boolean, - default: true - }, - }, - - computed: { - users() { - return this.extract ? this.extract(this.items) : this.items; - } - }, - - methods: { - userPage - } -}); +const pagingComponent = ref<InstanceType<typeof MkPagination>>(); </script> <style lang="scss" scoped> .efvhhmdq { - > .no-users { - text-align: center; - } - - > .users { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - grid-gap: var(--margin); - } - - > .more { - display: block; - width: 100%; - padding: 16px; - - &:hover { - background: rgba(#000, 0.025); - } - - &:active { - background: rgba(#000, 0.05); - } - - &.fetching { - cursor: wait; - } - - > i { - margin-right: 4px; - } - } + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); } </style> diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue index 12f7129253..ccde5fbe55 100644 --- a/packages/client/src/components/widgets.vue +++ b/packages/client/src/components/widgets.vue @@ -10,7 +10,7 @@ <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton> </header> <XDraggable - v-model="_widgets" + v-model="widgets_" item-key="id" animation="150" > @@ -18,7 +18,7 @@ <div class="customize-container"> <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button> <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button> - <component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" @updateProps="updateWidget(element.id, $event)"/> + <component :ref="el => widgetRefs[element.id] = el" :is="`mkw-${element.name}`" :widget="element" @updateProps="updateWidget(element.id, $event)"/> </div> </template> </XDraggable> @@ -28,7 +28,7 @@ </template> <script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; +import { defineComponent, defineAsyncComponent, reactive, ref, computed } from 'vue'; import { v4 as uuid } from 'uuid'; import MkSelect from '@/components/form/select.vue'; import MkButton from '@/components/ui/button.vue'; @@ -54,50 +54,47 @@ export default defineComponent({ emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'], - data() { - return { - widgetAdderSelected: null, - widgetDefs, - settings: {}, + setup(props, context) { + const widgetRefs = reactive({}); + const configWidget = (id: string) => { + widgetRefs[id].configure(); }; - }, - - computed: { - _widgets: { - get() { - return this.widgets; - }, - set(value) { - this.$emit('updateWidgets', value); - } - } - }, - - methods: { - configWidget(id) { - this.settings[id](); - }, + const widgetAdderSelected = ref(null); + const addWidget = () => { + if (widgetAdderSelected.value == null) return; - addWidget() { - if (this.widgetAdderSelected == null) return; - - this.$emit('addWidget', { - name: this.widgetAdderSelected, + context.emit('addWidget', { + name: widgetAdderSelected.value, id: uuid(), - data: {} + data: {}, }); - this.widgetAdderSelected = null; - }, - - removeWidget(widget) { - this.$emit('removeWidget', widget); - }, + widgetAdderSelected.value = null; + }; + const removeWidget = (widget) => { + context.emit('removeWidget', widget); + }; + const updateWidget = (id, data) => { + context.emit('updateWidget', { id, data }); + }; + const widgets_ = computed({ + get: () => props.widgets, + set: (value) => { + context.emit('updateWidgets', value); + }, + }); - updateWidget(id, data) { - this.$emit('updateWidget', { id, data }); - }, - } + return { + widgetRefs, + configWidget, + widgetAdderSelected, + widgetDefs, + addWidget, + removeWidget, + updateWidget, + widgets_, + }; + }, }); </script> |