refactor: Composition APIへ移行 (#8121)
* components/abuse-report-window.vue * use <script setup> * ✌️ * components/analog-clock.vue * wip components/autocomplete.vue * ✌️ * ✌️ * fix * wip components/captcha.vue * clean up * components/channel-follow-button * components/channel-preview.vue * components/core-core.vue * components/code.vue * wip components/date-separated-list.vue * fix * fix autocomplete.vue * ✌️ * remove global property * use <script setup> * components/dialog.vue * clena up * fix dialog.vue * Resolve https://github.com/misskey-dev/misskey/pull/8121#discussion_r781250966
This commit is contained in:
		
							parent
							
								
									06125e6820
								
							
						
					
					
						commit
						8855a5fffb
					
				
					 11 changed files with 614 additions and 756 deletions
				
			
		|  | @ -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> | ||||
|  |  | |||
|  | @ -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 | ||||
| 		} | ||||
| 	}, | ||||
| withDefaults(defineProps<{ | ||||
| 	thickness: number; | ||||
| }>(), { | ||||
| 	thickness: 0.1, | ||||
| }); | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			now: new Date(), | ||||
| 			enabled: true, | ||||
| 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); | ||||
| 
 | ||||
| 			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)'; | ||||
| 		}, | ||||
| 
 | ||||
| 		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(); | ||||
| 		}, | ||||
| 
 | ||||
| 		s(): number { | ||||
| 			return this.now.getSeconds(); | ||||
| 		}, | ||||
| 		m(): number { | ||||
| 			return this.now.getMinutes(); | ||||
| 		}, | ||||
| 		h(): number { | ||||
| 			return this.now.getHours(); | ||||
| 		}, | ||||
| 
 | ||||
| 		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; | ||||
| 		}, | ||||
| 
 | ||||
| 		graduations(): any { | ||||
| 			const angles = []; | ||||
| 			for (let i = 0; i < 60; i++) { | ||||
| 				const angle = Math.PI * i / 30; | ||||
| 				angles.push(angle); | ||||
| 			} | ||||
| 
 | ||||
| 			return angles; | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	mounted() { | ||||
| 		const update = () => { | ||||
| 			if (this.enabled) { | ||||
| 				this.tick(); | ||||
| 				setTimeout(update, 1000); | ||||
| 			} | ||||
| 		}; | ||||
| 		update(); | ||||
| 	}, | ||||
| 
 | ||||
| 	beforeUnmount() { | ||||
| 		this.enabled = false; | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		tick() { | ||||
| 			this.now = new Date(); | ||||
| 		} | ||||
| 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); | ||||
| 	} | ||||
| 
 | ||||
| 	return angles; | ||||
| }); | ||||
| 
 | ||||
| function tick() { | ||||
| 	now.value = new Date(); | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	const update = () => { | ||||
| 		if (enabled.value) { | ||||
| 			tick(); | ||||
| 			setTimeout(update, 1000); | ||||
| 		} | ||||
| 	}; | ||||
| 	update(); | ||||
| }); | ||||
| 
 | ||||
| onBeforeUnmount(() => { | ||||
| 	enabled.value = false; | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -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)); | ||||
| 			}); | ||||
| 		} | ||||
| 	} 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 { | ||||
| 				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; | ||||
| 		} | ||||
| 
 | ||||
| 		const matched: EmojiDef[] = []; | ||||
| 		const max = 30; | ||||
| 
 | ||||
| 		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 (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; | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		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; | ||||
| 			}); | ||||
| 		} | ||||
| 
 | ||||
| 		emojis.value = matched; | ||||
| 	} else if (props.type === 'mfmTag') { | ||||
| 		if (!props.q || props.q == '') { | ||||
| 			mfmTags.value = MFM_TAGS; | ||||
| 			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(); | ||||
| } | ||||
| 
 | ||||
| function onKeydown(e: KeyboardEvent) { | ||||
| 	const cancel = () => { | ||||
| 		e.preventDefault(); | ||||
| 		e.stopPropagation(); | ||||
| 	}; | ||||
| 
 | ||||
| 	switch (e.key) { | ||||
| 		case 'Enter': | ||||
| 			if (select.value !== -1) { | ||||
| 				cancel(); | ||||
| 				(items.value[select.value] as any).click(); | ||||
| 			} else { | ||||
| 				props.close(); | ||||
| 			} | ||||
| 			break; | ||||
| 
 | ||||
| 		case 'Escape': | ||||
| 			cancel(); | ||||
| 			props.close(); | ||||
| 			break; | ||||
| 
 | ||||
| 		case 'ArrowUp': | ||||
| 			if (select.value !== -1) { | ||||
| 				cancel(); | ||||
| 				selectPrev(); | ||||
| 			} else { | ||||
| 				props.close(); | ||||
| 			} | ||||
| 			break; | ||||
| 
 | ||||
| 		case 'Tab': | ||||
| 		case 'ArrowDown': | ||||
| 			cancel(); | ||||
| 			selectNext(); | ||||
| 			break; | ||||
| 
 | ||||
| 		default: | ||||
| 			e.stopPropagation(); | ||||
| 			props.textarea.focus(); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function selectNext() { | ||||
| 	if (++select.value >= items.value.length) select.value = 0; | ||||
| 	if (items.value.length === 0) select.value = -1; | ||||
| 	applySelect(); | ||||
| } | ||||
| 
 | ||||
| function selectPrev() { | ||||
| 	if (--select.value < 0) select.value = items.value.length - 1; | ||||
| 	applySelect(); | ||||
| } | ||||
| 
 | ||||
| function applySelect() { | ||||
| 	for (const el of Array.from(items.value)) { | ||||
| 		el.removeAttribute('data-selected'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (select.value !== -1) { | ||||
| 		items.value[select.value].setAttribute('data-selected', 'true'); | ||||
| 		(items.value[select.value] as any).focus(); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| function chooseUser() { | ||||
| 	props.close(); | ||||
| 	os.selectUser().then(user => { | ||||
| 		complete('user', user); | ||||
| 		props.textarea.focus(); | ||||
| 	}); | ||||
| } | ||||
| 
 | ||||
| onUpdated(() => { | ||||
| 	setPosition(); | ||||
| 	items.value = suggests.value?.children || []; | ||||
| }); | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	setPosition(); | ||||
| 
 | ||||
| 	props.textarea.addEventListener('keydown', onKeydown); | ||||
| 
 | ||||
| 	for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 		el.addEventListener('mousedown', onMousedown); | ||||
| 	} | ||||
| 
 | ||||
| 	nextTick(() => { | ||||
| 		exec(); | ||||
| 
 | ||||
| 		watch(() => props.q, () => { | ||||
| 			nextTick(() => { | ||||
| 				exec(); | ||||
| 			}); | ||||
| 		}); | ||||
| 	}, | ||||
| 	}); | ||||
| }); | ||||
| 
 | ||||
| 	beforeUnmount() { | ||||
| 		this.textarea.removeEventListener('keydown', this.onKeydown); | ||||
| onBeforeUnmount(() => { | ||||
| 	props.textarea.removeEventListener('keydown', 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 { | ||||
| 				this.$el.style.left = this.x + 'px'; | ||||
| 			} | ||||
| 
 | ||||
| 			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)'; | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		exec() { | ||||
| 			this.select = -1; | ||||
| 			if (this.$refs.suggests) { | ||||
| 				for (const el of Array.from(this.items)) { | ||||
| 					el.removeAttribute('data-selected'); | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			if (this.type === 'user') { | ||||
| 				if (this.q == null) { | ||||
| 					this.users = []; | ||||
| 					this.fetching = false; | ||||
| 					return; | ||||
| 				} | ||||
| 
 | ||||
| 				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; | ||||
| 
 | ||||
| 						// キャッシュ | ||||
| 						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; | ||||
| 
 | ||||
| 							// キャッシュ | ||||
| 							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; | ||||
| 				} | ||||
| 
 | ||||
| 				const matched = []; | ||||
| 				const max = 30; | ||||
| 
 | ||||
| 				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; | ||||
| 					}); | ||||
| 				} | ||||
| 
 | ||||
| 				this.emojis = matched; | ||||
| 			} else if (this.type === 'mfmTag') { | ||||
| 				if (this.q == null || this.q == '') { | ||||
| 					this.mfmTags = MFM_TAGS; | ||||
| 					return; | ||||
| 				} | ||||
| 
 | ||||
| 				this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q)); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onMousedown(e) { | ||||
| 			if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); | ||||
| 		}, | ||||
| 
 | ||||
| 		onKeydown(e) { | ||||
| 			const cancel = () => { | ||||
| 				e.preventDefault(); | ||||
| 				e.stopPropagation(); | ||||
| 			}; | ||||
| 
 | ||||
| 			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; | ||||
| 
 | ||||
| 				case 27: // [ESC] | ||||
| 					cancel(); | ||||
| 					this.close(); | ||||
| 					break; | ||||
| 
 | ||||
| 				case 38: // [↑] | ||||
| 					if (this.select !== -1) { | ||||
| 						cancel(); | ||||
| 						this.selectPrev(); | ||||
| 					} else { | ||||
| 						this.close(); | ||||
| 					} | ||||
| 					break; | ||||
| 
 | ||||
| 				case 9: // [TAB] | ||||
| 				case 40: // [↓] | ||||
| 					cancel(); | ||||
| 					this.selectNext(); | ||||
| 					break; | ||||
| 
 | ||||
| 				default: | ||||
| 					e.stopPropagation(); | ||||
| 					this.textarea.focus(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		selectNext() { | ||||
| 			if (++this.select >= this.items.length) this.select = 0; | ||||
| 			if (this.items.length === 0) this.select = -1; | ||||
| 			this.applySelect(); | ||||
| 		}, | ||||
| 
 | ||||
| 		selectPrev() { | ||||
| 			if (--this.select < 0) this.select = this.items.length - 1; | ||||
| 			this.applySelect(); | ||||
| 		}, | ||||
| 
 | ||||
| 		applySelect() { | ||||
| 			for (const el of Array.from(this.items)) { | ||||
| 				el.removeAttribute('data-selected'); | ||||
| 			} | ||||
| 
 | ||||
| 			if (this.select !== -1) { | ||||
| 				this.items[this.select].setAttribute('data-selected', 'true'); | ||||
| 				(this.items[this.select] as any).focus(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		chooseUser() { | ||||
| 			this.close(); | ||||
| 			os.selectUser().then(user => { | ||||
| 				this.complete('user', user); | ||||
| 				this.textarea.focus(); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		acct | ||||
| 	for (const el of Array.from(document.querySelectorAll('body *'))) { | ||||
| 		el.removeEventListener('mousedown', onMousedown); | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
|  |  | |||
|  | @ -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); | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	mounted() { | ||||
| 		if (this.available) { | ||||
| 			this.requestRender(); | ||||
| 		} else { | ||||
| 			this.$watch('available', this.requestRender); | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	beforeUnmount() { | ||||
| 		this.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); | ||||
| 		}, | ||||
| 	}, | ||||
| 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); | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	if (available.value) { | ||||
| 		requestRender(); | ||||
| 	} else { | ||||
| 		watch(available, requestRender); | ||||
| 	} | ||||
| }); | ||||
| 
 | ||||
| onBeforeUnmount(() => { | ||||
| 	reset(); | ||||
| }); | ||||
| 
 | ||||
| defineExpose({ | ||||
| 	reset, | ||||
| }); | ||||
| 
 | ||||
| </script> | ||||
|  |  | |||
|  | @ -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, | ||||
| 		}, | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			isFollowing: this.channel.isFollowing, | ||||
| 			wait: false, | ||||
| 		}; | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		async onClick() { | ||||
| 			this.wait = 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; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	channel: Record<string, any>; | ||||
| 	full?: boolean; | ||||
| }>(), { | ||||
| 	full: false, | ||||
| }); | ||||
| 
 | ||||
| const isFollowing = ref<boolean>(props.channel.isFollowing); | ||||
| const wait = ref(false); | ||||
| 
 | ||||
| async function onClick() { | ||||
| 	wait.value = true; | ||||
| 
 | ||||
| 	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> | ||||
|  |  | |||
|  | @ -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> | ||||
| 
 | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 = () => this.items.map((item, i) => { | ||||
| 			const el = this.$slots.default({ | ||||
| 		const renderChildren = () => props.items.map((item, i) => { | ||||
| 			if (!slots || !slots.default) return; | ||||
| 
 | ||||
| 			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> | ||||
| 
 | ||||
|  |  | |||
|  | @ -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(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onKeydown(e) { | ||||
| 			if (e.which === 27) { // ESC | ||||
| 				this.cancel(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		onInputKeydown(e) { | ||||
| 			if (e.which === 13) { // Enter | ||||
| 				e.preventDefault(); | ||||
| 				e.stopPropagation(); | ||||
| 				this.ok(); | ||||
| 			} | ||||
| 		} | ||||
| function onInputKeydown(e: KeyboardEvent) { | ||||
| 	if (e.key === 'Enter') { | ||||
| 		e.preventDefault(); | ||||
| 		e.stopPropagation(); | ||||
| 		ok(); | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| onMounted(() => { | ||||
| 	document.addEventListener('keydown', onKeydown); | ||||
| }); | ||||
| 
 | ||||
| onBeforeUnmount(() => { | ||||
| 	document.removeEventListener('keydown', onKeydown); | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue