diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/client/src/components | |
| parent | update deps (diff) | |
| download | sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/client/src/components')
174 files changed, 26267 insertions, 0 deletions
diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue new file mode 100644 index 0000000000..700ce30bb2 --- /dev/null +++ b/packages/client/src/components/abuse-report-window.vue @@ -0,0 +1,79 @@ +<template> +<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"> + <template #name> + <b><MkAcct :user="user"/></b> + </template> + </I18n> + </template> + <div class="dpvffvvy _monolithic_"> + <div class="_section"> + <MkTextarea v-model="comment"> + <template #label>{{ $ts.details }}</template> + <template #caption>{{ $ts.fillAbuseReportDescription }}</template> + </MkTextarea> + </div> + <div class="_section"> + <MkButton @click="send" primary full :disabled="comment.length === 0">{{ $ts.send }}</MkButton> + </div> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +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'; + +export default defineComponent({ + components: { + XWindow, + MkTextarea, + MkButton, + }, + + props: { + user: { + type: Object, + required: true, + }, + initialComment: { + type: String, + required: false, + }, + }, + + emits: ['closed'], + + data() { + return { + comment: this.initialComment || '', + }; + }, + + methods: { + send() { + os.apiWithDialog('users/report-abuse', { + userId: this.user.id, + comment: this.comment, + }, undefined, res => { + os.dialog({ + type: 'success', + text: this.$ts.abuseReported + }); + this.$refs.window.close(); + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.dpvffvvy { + --root-margin: 16px; +} +</style> diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue new file mode 100644 index 0000000000..bc572e5fff --- /dev/null +++ b/packages/client/src/components/analog-clock.vue @@ -0,0 +1,150 @@ +<template> +<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none"> + <circle v-for="(angle, i) in graduations" + :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))" + :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))" + :r="i % 5 == 0 ? 0.125 : 0.05" + :fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor" + :key="i" + /> + + <line + :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" + :stroke="sHandColor" + :stroke-width="thickness / 2" + stroke-linecap="round" + /> + + <line + :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" + :stroke="mHandColor" + :stroke-width="thickness" + stroke-linecap="round" + /> + + <line + :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" + :stroke="hHandColor" + :stroke-width="thickness" + stroke-linecap="round" + /> +</svg> +</template> + +<script lang="ts"> +import { defineComponent } 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)'; + }, + + 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(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mbcofsoe { + display: block; +} +</style> diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue new file mode 100644 index 0000000000..a7d2d507e0 --- /dev/null +++ b/packages/client/src/components/autocomplete.vue @@ -0,0 +1,501 @@ +<template> +<div class="swhvrteh _popup _shadow" @contextmenu.prevent="() => {}"> + <ol class="users" ref="suggests" v-if="type === 'user'"> + <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user"> + <img class="avatar" :src="user.avatarUrl"/> + <span class="name"> + <MkUserName :user="user" :key="user.id"/> + </span> + <span class="username">@{{ acct(user) }}</span> + </li> + <li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $ts.selectUser }}</li> + </ol> + <ol class="hashtags" ref="suggests" v-else-if="hashtags.length > 0"> + <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1"> + <span class="name">{{ hashtag }}</span> + </li> + </ol> + <ol class="emojis" ref="suggests" v-else-if="emojis.length > 0"> + <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> + <span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> + <span class="emoji" v-else-if="!$store.state.useOsNativeEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span> + <span class="emoji" v-else>{{ emoji.emoji }}</span> + <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> + <span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span> + </li> + </ol> + <ol class="mfmTags" ref="suggests" v-else-if="mfmTags.length > 0"> + <li v-for="tag in mfmTags" @click="complete(type, tag)" @keydown="onKeydown" tabindex="-1"> + <span class="tag">{{ tag }}</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { emojilist } from '@/scripts/emojilist'; +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'; + +type EmojiDef = { + emoji: string; + name: string; + aliasOf?: string; + url?: string; + isCustomEmoji?: boolean; +}; + +const lib = emojilist.filter(x => x.category !== 'flags'); + +const char2file = (char: string) => { + let codes = Array.from(char).map(x => x.codePointAt(0).toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); + return codes.join('-'); +}; + +const emjdb: EmojiDef[] = lib.map(x => ({ + emoji: x.char, + name: x.name, + aliasOf: null, + url: `${twemojiSvgBase}/${char2file(x.char)}.svg` +})); + +for (const x of lib) { + if (x.keywords) { + for (const k of x.keywords) { + emjdb.push({ + emoji: x.char, + name: k, + aliasOf: x.name, + url: `${twemojiSvgBase}/${char2file(x.char)}.svg` + }); + } + } +} + +emjdb.sort((a, b) => a.name.length - b.name.length); + +//#region Construct Emoji DB +const customEmojis = instance.emojis; +const emojiDefinitions: EmojiDef[] = []; + +for (const x of customEmojis) { + emojiDefinitions.push({ + name: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true + }); + + if (x.aliases) { + for (const alias of x.aliases) { + emojiDefinitions.push({ + name: alias, + aliasOf: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true + }); + } + } +} + +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, + }, + + q: { + type: String, + required: false, + }, + + textarea: { + type: HTMLTextAreaElement, + required: true, + }, + + close: { + type: Function, + required: true, + }, + + x: { + type: Number, + required: true, + }, + + y: { + type: Number, + required: true, + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + getStaticImageUrl, + fetching: true, + users: [], + hashtags: [], + emojis: [], + items: [], + mfmTags: [], + select: -1, + } + }, + + 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); + } + + this.$nextTick(() => { + this.exec(); + + this.$watch('q', () => { + this.$nextTick(() => { + this.exec(); + }); + }); + }); + }, + + 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 { + 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 + } +}); +</script> + +<style lang="scss" scoped> +.swhvrteh { + position: fixed; + z-index: 65535; + max-width: 100%; + margin-top: calc(1em + 8px); + overflow: hidden; + transition: top 0.1s ease, left 0.1s ease; + + > ol { + display: block; + margin: 0; + padding: 4px 0; + max-height: 190px; + max-width: 500px; + overflow: auto; + list-style: none; + + > li { + display: flex; + align-items: center; + padding: 4px 12px; + white-space: nowrap; + overflow: hidden; + font-size: 0.9em; + cursor: default; + + &, * { + user-select: none; + } + + * { + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + background: var(--X3); + } + + &[data-selected='true'] { + background: var(--accent); + + &, * { + color: #fff !important; + } + } + + &:active { + background: var(--accentDarken); + + &, * { + color: #fff !important; + } + } + } + } + + > .users > li { + + .avatar { + min-width: 28px; + min-height: 28px; + max-width: 28px; + max-height: 28px; + margin: 0 8px 0 0; + border-radius: 100%; + } + + .name { + margin: 0 8px 0 0; + } + } + + > .emojis > li { + + .emoji { + display: inline-block; + margin: 0 4px 0 0; + width: 24px; + + > img { + width: 24px; + vertical-align: bottom; + } + } + + .alias { + margin: 0 0 0 8px; + } + } + + > .mfmTags > li { + + .name { + } + } +} +</style> diff --git a/packages/client/src/components/avatars.vue b/packages/client/src/components/avatars.vue new file mode 100644 index 0000000000..e843d26daa --- /dev/null +++ b/packages/client/src/components/avatars.vue @@ -0,0 +1,30 @@ +<template> +<div> + <div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> + <MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + userIds: { + required: true + }, + }, + data() { + return { + us: [] + }; + }, + async created() { + this.us = await os.api('users/show', { + userIds: this.userIds + }); + } +}); +</script> diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue new file mode 100644 index 0000000000..baa922506e --- /dev/null +++ b/packages/client/src/components/captcha.vue @@ -0,0 +1,123 @@ +<template> +<div> + <span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span> + <div ref="captcha"></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; + +type Captcha = { + render(container: string | Node, options: { + readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; + }): string; + remove(id: string): void; + execute(id: string): void; + reset(id: string): void; + getResponse(id: string): string; +}; + +type CaptchaProvider = 'hcaptcha' | 'recaptcha'; + +type CaptchaContainer = { + readonly [_ in CaptchaProvider]?: Captcha; +}; + +declare global { + interface Window extends CaptchaContainer { + } +} + +export default defineComponent({ + props: { + provider: { + type: String as PropType<CaptchaProvider>, + required: true, + }, + sitekey: { + type: String, + required: true, + }, + modelValue: { + type: String, + }, + }, + + data() { + return { + available: false, + }; + }, + + 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]; + + return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; + }, + captcha(): Captcha { + return window[this.variable] || {} as unknown as Captcha; + }, + }, + + 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); + }, + }, +}); +</script> diff --git a/packages/client/src/components/channel-follow-button.vue b/packages/client/src/components/channel-follow-button.vue new file mode 100644 index 0000000000..9af65325bb --- /dev/null +++ b/packages/client/src/components/channel-follow-button.vue @@ -0,0 +1,140 @@ +<template> +<button class="hdcaacmi _button" + :class="{ wait, active: isFollowing, full }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait"> + <template v-if="isFollowing"> + <span v-if="full">{{ $ts.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> + </template> + </template> + <template v-else> + <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> + </template> +</button> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +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; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.hdcaacmi { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + height: 31px; + font-size: 16px; + border-radius: 32px; + background: #fff; + + &.full { + padding: 0 8px 0 12px; + font-size: 14px; + } + + &:not(.full) { + width: 31px; + } + + &:focus-visible { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 32px; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } + + > span { + margin-right: 6px; + } +} +</style> diff --git a/packages/client/src/components/channel-preview.vue b/packages/client/src/components/channel-preview.vue new file mode 100644 index 0000000000..eb00052a78 --- /dev/null +++ b/packages/client/src/components/channel-preview.vue @@ -0,0 +1,165 @@ +<template> +<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> + <div class="banner" :style="bannerStyle"> + <div class="fade"></div> + <div class="name"><i class="fas fa-satellite-dish"></i> {{ channel.name }}</div> + <div class="status"> + <div> + <i class="fas fa-users fa-fw"></i> + <I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.usersCount }}</b> + </template> + </I18n> + </div> + <div> + <i class="fas fa-pencil-alt fa-fw"></i> + <I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.notesCount }}</b> + </template> + </I18n> + </div> + </div> + </div> + <article v-if="channel.description"> + <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> + </article> + <footer> + <span v-if="channel.lastNotedAt"> + {{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> + </span> + </footer> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + channel: { + type: Object, + required: true + }, + }, + + computed: { + bannerStyle() { + if (this.channel.bannerUrl) { + return { backgroundImage: `url(${this.channel.bannerUrl})` }; + } else { + return { backgroundColor: '#4c5e6d' }; + } + } + }, + + data() { + return { + }; + }, +}); +</script> + +<style lang="scss" scoped> +.eftoefju { + display: block; + overflow: hidden; + width: 100%; + + &:hover { + text-decoration: none; + } + + > .banner { + position: relative; + width: 100%; + height: 200px; + background-position: center; + background-size: cover; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + + > .name { + position: absolute; + top: 16px; + left: 16px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 1.2em; + } + + > .status { + position: absolute; + z-index: 1; + bottom: 16px; + right: 16px; + padding: 8px 12px; + font-size: 80%; + background: rgba(0, 0, 0, 0.7); + border-radius: 6px; + color: #fff; + } + } + + > article { + padding: 16px; + + > p { + margin: 0; + font-size: 1em; + } + } + + > footer { + padding: 12px 16px; + border-top: solid 0.5px var(--divider); + + > span { + opacity: 0.7; + font-size: 0.9em; + } + } + + @media (max-width: 550px) { + font-size: 0.9em; + + > .banner { + height: 80px; + + > .status { + display: none; + } + } + + > article { + padding: 12px; + } + + > footer { + display: none; + } + } + + @media (max-width: 500px) { + font-size: 0.8em; + + > .banner { + height: 70px; + } + + > article { + padding: 8px; + } + } +} + +</style> diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue new file mode 100644 index 0000000000..c4d0eb85dd --- /dev/null +++ b/packages/client/src/components/chart.vue @@ -0,0 +1,691 @@ +<template> +<div class="cbbedffa"> + <canvas ref="chartEl"></canvas> + <div v-if="fetching" class="fetching"> + <MkLoading/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref, watch, PropType } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import { enUS } from 'date-fns/locale'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + zoomPlugin, +); + +const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = arr => arr.map(x => -x); +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560']; +const getColor = (i) => { + return colors[i % colors.length]; +}; + +export default defineComponent({ + props: { + src: { + type: String, + required: true, + }, + args: { + type: Object, + required: false, + }, + limit: { + type: Number, + required: false, + default: 90 + }, + span: { + type: String as PropType<'hour' | 'day'>, + required: true, + }, + detailed: { + type: Boolean, + required: false, + default: false + }, + stacked: { + type: Boolean, + required: false, + default: false + }, + aspectRatio: { + type: Number, + required: false, + default: null + }, + }, + + setup(props) { + const now = new Date(); + let chartInstance: Chart = null; + let data: { + series: { + name: string; + type: 'line' | 'area'; + color?: string; + borderDash?: number[]; + hidden?: boolean; + data: { + x: number; + y: number; + }[]; + }[]; + } = null; + + const chartEl = ref<HTMLCanvasElement>(null); + const fetching = ref(true); + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v + })); + }; + + const render = () => { + if (chartInstance) { + chartInstance.destroy(); + } + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), + datasets: data.series.map((x, i) => ({ + parsing: false, + label: x.name, + data: x.data.slice().reverse(), + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: x.color ? x.color : getColor(i), + borderDash: x.borderDash || [], + borderJoinStyle: 'round', + backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1), + fill: x.type === 'area', + hidden: !!x.hidden, + })), + }, + options: { + aspectRatio: props.aspectRatio || 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 8, + }, + }, + scales: { + x: { + type: 'time', + time: { + stepSize: 1, + unit: props.span === 'day' ? 'month' : 'day', + }, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(props.limit).getTime(), + }, + y: { + position: 'left', + stacked: props.stacked, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: props.detailed, + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + tooltip: { + mode: 'index', + animation: { + duration: 0, + }, + }, + zoom: { + pan: { + enabled: true, + }, + zoom: { + wheel: { + enabled: true, + }, + pinch: { + enabled: true, + }, + drag: { + enabled: false, + }, + mode: 'x', + }, + limits: { + x: { + min: 'original', + max: 'original', + }, + y: { + min: 'original', + max: 'original', + }, + } + }, + }, + }, + }); + }; + + const exportData = () => { + // TODO + }; + + const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Instances', + type: 'area', + data: format(total + ? raw.instance.total + : sum(raw.instance.inc, negate(raw.instance.dec)) + ), + }], + }; + }; + + const fetchNotesChart = async (type: string): Promise<typeof data> => { + const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format(type == 'combined' + ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + : sum(raw[type].inc, negate(raw[type].dec)) + ), + }, { + name: 'Renotes', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) + : raw[type].diffs.renote + ), + }, { + name: 'Replies', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) + : raw[type].diffs.reply + ), + }, { + name: 'Normal', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) + : raw[type].diffs.normal + ), + }], + }; + }; + + const fetchNotesTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.total, raw.remote.total)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.total), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.total), + }], + }; + }; + + const fetchUsersChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(total + ? sum(raw.local.total, raw.remote.total) + : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + ), + }, { + name: 'Local', + type: 'area', + data: format(total + ? raw.local.total + : sum(raw.local.inc, negate(raw.local.dec)) + ), + }, { + name: 'Remote', + type: 'area', + data: format(total + ? raw.remote.total + : sum(raw.remote.inc, negate(raw.remote.dec)) + ), + }], + }; + }; + + const fetchActiveUsersChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.users, raw.remote.users)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.users), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.users), + }], + }; + }; + + const fetchDriveChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format( + sum( + raw.local.incSize, + negate(raw.local.decSize), + raw.remote.incSize, + negate(raw.remote.decSize) + ) + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incSize), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decSize)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incSize), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decSize)), + }], + }; + }; + + const fetchDriveTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.totalSize, raw.remote.totalSize)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.totalSize), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.totalSize), + }], + }; + }; + + const fetchDriveFilesChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format( + sum( + raw.local.incCount, + negate(raw.local.decCount), + raw.remote.incCount, + negate(raw.remote.decCount) + ) + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incCount), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decCount)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incCount), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decCount)), + }], + }; + }; + + const fetchDriveFilesTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.totalCount, raw.remote.totalCount)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.totalCount), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.totalCount), + }], + }; + }; + + const fetchInstanceRequestsChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'In', + type: 'area', + color: '#008FFB', + data: format(raw.requests.received) + }, { + name: 'Out (succ)', + type: 'area', + color: '#00E396', + data: format(raw.requests.succeeded) + }, { + name: 'Out (fail)', + type: 'area', + color: '#FEB019', + data: format(raw.requests.failed) + }] + }; + }; + + const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Users', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.users.total + : sum(raw.users.inc, negate(raw.users.dec)) + ) + }] + }; + }; + + const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Notes', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.notes.total + : sum(raw.notes.inc, negate(raw.notes.dec)) + ) + }] + }; + }; + + const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Following', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.following.total + : sum(raw.following.inc, negate(raw.following.dec)) + ) + }, { + name: 'Followers', + type: 'area', + color: '#00E396', + data: format(total + ? raw.followers.total + : sum(raw.followers.inc, negate(raw.followers.dec)) + ) + }] + }; + }; + + const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'Drive usage', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalUsage + : sum(raw.drive.incUsage, negate(raw.drive.decUsage)) + ) + }] + }; + }; + + const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Drive files', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalFiles + : sum(raw.drive.incFiles, negate(raw.drive.decFiles)) + ) + }] + }; + }; + + const fetchPerUserNotesChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + return { + series: [...(props.args.withoutAll ? [] : [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format(sum(raw.inc, negate(raw.dec))), + }]), { + name: 'Renotes', + type: 'area', + data: format(raw.diffs.renote), + }, { + name: 'Replies', + type: 'area', + data: format(raw.diffs.reply), + }, { + name: 'Normal', + type: 'area', + data: format(raw.diffs.normal), + }], + }; + }; + + const fetchAndRender = async () => { + const fetchData = () => { + switch (props.src) { + case 'federation-instances': return fetchFederationInstancesChart(false); + case 'federation-instances-total': return fetchFederationInstancesChart(true); + case 'users': return fetchUsersChart(false); + case 'users-total': return fetchUsersChart(true); + case 'active-users': return fetchActiveUsersChart(); + case 'notes': return fetchNotesChart('combined'); + case 'local-notes': return fetchNotesChart('local'); + case 'remote-notes': return fetchNotesChart('remote'); + case 'notes-total': return fetchNotesTotalChart(); + case 'drive': return fetchDriveChart(); + case 'drive-total': return fetchDriveTotalChart(); + case 'drive-files': return fetchDriveFilesChart(); + case 'drive-files-total': return fetchDriveFilesTotalChart(); + + case 'instance-requests': return fetchInstanceRequestsChart(); + case 'instance-users': return fetchInstanceUsersChart(false); + case 'instance-users-total': return fetchInstanceUsersChart(true); + case 'instance-notes': return fetchInstanceNotesChart(false); + case 'instance-notes-total': return fetchInstanceNotesChart(true); + case 'instance-ff': return fetchInstanceFfChart(false); + case 'instance-ff-total': return fetchInstanceFfChart(true); + case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false); + case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true); + case 'instance-drive-files': return fetchInstanceDriveFilesChart(false); + case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true); + + case 'per-user-notes': return fetchPerUserNotesChart(); + } + }; + fetching.value = true; + data = await fetchData(); + fetching.value = false; + render(); + }; + + watch(() => [props.src, props.span], fetchAndRender); + + onMounted(() => { + fetchAndRender(); + }); + + return { + chartEl, + fetching, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.cbbedffa { + position: relative; + + > .fetching { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--blur, blur(12px)); + backdrop-filter: var(--blur, blur(12px)); + display: flex; + justify-content: center; + align-items: center; + cursor: wait; + } +} +</style> diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue new file mode 100644 index 0000000000..9cff7b4448 --- /dev/null +++ b/packages/client/src/components/code-core.vue @@ -0,0 +1,35 @@ +<template> +<code v-if="inline" v-html="html" :class="`language-${prismLang}`"></code> +<pre v-else :class="`language-${prismLang}`"><code v-html="html" :class="`language-${prismLang}`"></code></pre> +</template> + +<script lang="ts"> +import { defineComponent } 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); + } + } +}); +</script> diff --git a/packages/client/src/components/code.vue b/packages/client/src/components/code.vue new file mode 100644 index 0000000000..f5d6c5673a --- /dev/null +++ b/packages/client/src/components/code.vue @@ -0,0 +1,27 @@ +<template> +<XCode :code="code" :lang="lang" :inline="inline"/> +</template> + +<script lang="ts"> +import { defineComponent, 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 + } + } +}); +</script> diff --git a/packages/client/src/components/cw-button.vue b/packages/client/src/components/cw-button.vue new file mode 100644 index 0000000000..4bec7b511e --- /dev/null +++ b/packages/client/src/components/cw-button.vue @@ -0,0 +1,70 @@ +<template> +<button class="nrvgflfu _button" @click="toggle"> + <b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b> + <span v-if="!modelValue">{{ label }}</span> +</button> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { length } from 'stringz'; +import { concat } from '@/scripts/array'; + +export default defineComponent({ + props: { + modelValue: { + type: Boolean, + required: true + }, + note: { + type: Object, + required: true + } + }, + + computed: { + label(): string { + return concat([ + this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [], + this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [], + this.note.poll != null ? [this.$ts.poll] : [] + ] as string[][]).join(' / '); + } + }, + + methods: { + length, + + toggle() { + this.$emit('update:modelValue', !this.modelValue); + } + } +}); +</script> + +<style lang="scss" scoped> +.nrvgflfu { + display: inline-block; + padding: 4px 8px; + font-size: 0.7em; + color: var(--cwFg); + background: var(--cwBg); + border-radius: 2px; + + &:hover { + background: var(--cwHoverBg); + } + + > span { + margin-left: 4px; + + &:before { + content: '('; + } + + &:after { + content: ')'; + } + } +} +</style> diff --git a/packages/client/src/components/date-separated-list.vue b/packages/client/src/components/date-separated-list.vue new file mode 100644 index 0000000000..1aea9fd353 --- /dev/null +++ b/packages/client/src/components/date-separated-list.vue @@ -0,0 +1,188 @@ +<script lang="ts"> +import { defineComponent, h, PropType, TransitionGroup } from 'vue'; +import MkAd from '@/components/global/ad.vue'; + +export default defineComponent({ + props: { + items: { + type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, + required: true, + }, + direction: { + type: String, + required: false, + default: 'down' + }, + reversed: { + type: Boolean, + required: false, + default: false + }, + noGap: { + type: Boolean, + required: false, + default: false + }, + ad: { + type: Boolean, + required: false, + default: false + }, + }, + + methods: { + focus() { + this.$slots.default[0].elm.focus(); + }, + + getDateText(time: string) { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return this.$t('monthAndDay', { + month: month.toString(), + day: date.toString() + }); + } + }, + + render() { + if (this.items.length === 0) return; + + const renderChildren = () => this.items.map((item, i) => { + const el = this.$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() + ) { + const separator = h('div', { + class: 'separator', + key: item.id + ':separator', + }, h('p', { + class: 'date' + }, [ + h('span', [ + h('i', { + class: 'fas fa-angle-up icon', + }), + this.getDateText(item.createdAt) + ]), + h('span', [ + this.getDateText(this.items[i + 1].createdAt), + h('i', { + class: 'fas fa-angle-down icon', + }) + ]) + ])); + + return [el, separator]; + } else { + if (this.ad && item._shouldInsertAd_) { + return [h(MkAd, { + class: 'a', // advertiseの意(ブロッカー対策) + key: item.id + ':ad', + prefer: ['horizontal', 'horizontal-big'], + }), el]; + } else { + return el; + } + } + }); + + 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 + }); + }, +}); +</script> + +<style lang="scss"> +.sqadhkmv { + > *:empty { + display: none; + } + + > *:not(:last-child) { + margin-bottom: var(--margin); + } + + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + &[data-direction="up"] { + > .list-enter-from { + opacity: 0; + transform: translateY(64px); + } + } + + &[data-direction="down"] { + > .list-enter-from { + opacity: 0; + transform: translateY(-64px); + } + } + + > .separator { + text-align: center; + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } + + &.noGap { + > * { + margin: 0 !important; + border: none; + border-radius: 0; + box-shadow: none; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/base.vue b/packages/client/src/components/debobigego/base.vue new file mode 100644 index 0000000000..f551a3478b --- /dev/null +++ b/packages/client/src/components/debobigego/base.vue @@ -0,0 +1,65 @@ +<template> +<div class="rbusrurv" :class="{ wide: forceWide }" v-size="{ max: [400] }"> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + forceWide: { + type: Boolean, + required: false, + default: false, + } + } +}); +</script> + +<style lang="scss" scoped> +.rbusrurv { + // 他のCSSからも参照されるので消さないように + --debobigegoXPadding: 32px; + --debobigegoYPadding: 32px; + + --debobigegoContentHMargin: 16px; + + font-size: 95%; + line-height: 1.3em; + background: var(--bg); + padding: var(--debobigegoYPadding) var(--debobigegoXPadding); + max-width: 750px; + margin: 0 auto; + + &:not(.wide).max-width_400px { + --debobigegoXPadding: 0px; + + > ::v-deep(*) { + ._debobigegoPanel { + border: solid 0.5px var(--divider); + border-radius: 0; + border-left: none; + border-right: none; + } + + ._debobigego_group { + > *:not(._debobigegoNoConcat) { + &:not(:last-child):not(._debobigegoNoConcatPrev) { + &._debobigegoPanel, ._debobigegoPanel { + border-bottom: solid 0.5px var(--divider); + } + } + + &:not(:first-child):not(._debobigegoNoConcatNext) { + &._debobigegoPanel, ._debobigegoPanel { + border-top: none; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/button.vue b/packages/client/src/components/debobigego/button.vue new file mode 100644 index 0000000000..b883e817a4 --- /dev/null +++ b/packages/client/src/components/debobigego/button.vue @@ -0,0 +1,81 @@ +<template> +<div class="yzpgjkxe _debobigegoItem"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <button class="main _button _debobigegoPanel _debobigegoClickable" :class="{ center, primary, danger }"> + <slot></slot> + <div class="suffix"> + <slot name="suffix"></slot> + <div class="icon"> + <slot name="suffixIcon"></slot> + </div> + </div> + </button> + <div class="_debobigegoCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + primary: { + type: Boolean, + required: false, + default: false, + }, + danger: { + type: Boolean, + required: false, + default: false, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + center: { + type: Boolean, + required: false, + default: true, + } + }, +}); +</script> + +<style lang="scss" scoped> +.yzpgjkxe { + > .main { + display: flex; + width: 100%; + box-sizing: border-box; + padding: 14px 16px; + text-align: left; + align-items: center; + + &.center { + display: block; + text-align: center; + } + + &.primary { + color: var(--accent); + } + + &.danger { + color: #ff2a2a; + } + + > .suffix { + display: inline-flex; + margin-left: auto; + opacity: 0.7; + + > .icon { + margin-left: 1em; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/debobigego.scss b/packages/client/src/components/debobigego/debobigego.scss new file mode 100644 index 0000000000..833b656b66 --- /dev/null +++ b/packages/client/src/components/debobigego/debobigego.scss @@ -0,0 +1,52 @@ +._debobigegoPanel { + background: var(--panel); + border-radius: var(--radius); + transition: background 0.2s ease; + + &._debobigegoClickable { + &:hover { + //background: var(--panelHighlight); + } + + &:active { + background: var(--panelHighlight); + transition: background 0s; + } + } +} + +._debobigegoLabel, +._debobigegoCaption { + font-size: 80%; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } +} + +._debobigegoLabel { + position: sticky; + top: var(--stickyTop, 0px); + z-index: 2; + margin: -8px calc(var(--debobigegoXPadding) * -1) 0 calc(var(--debobigegoXPadding) * -1); + padding: 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)) 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)); + background: var(--X17); + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); +} + +._themeChanging_ ._debobigegoLabel { + transition: none !important; + background: transparent; +} + +._debobigegoCaption { + padding: 8px var(--debobigegoContentHMargin) 0 var(--debobigegoContentHMargin); +} + +._debobigegoItem { + & + ._debobigegoItem { + margin-top: 24px; + } +} diff --git a/packages/client/src/components/debobigego/group.vue b/packages/client/src/components/debobigego/group.vue new file mode 100644 index 0000000000..cba2c6ec94 --- /dev/null +++ b/packages/client/src/components/debobigego/group.vue @@ -0,0 +1,78 @@ +<template> +<div class="vrtktovg _debobigegoItem _debobigegoNoConcat" v-size="{ max: [500] }" v-sticky-container> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="main _debobigego_group" ref="child"> + <slot></slot> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref } from 'vue'; + +export default defineComponent({ + setup(props, context) { + const child = ref<HTMLElement | null>(null); + + const scanChild = () => { + if (child.value == null) return; + const els = Array.from(child.value.children); + for (let i = 0; i < els.length; i++) { + const el = els[i]; + if (el.classList.contains('_debobigegoNoConcat')) { + if (els[i - 1]) els[i - 1].classList.add('_debobigegoNoConcatPrev'); + if (els[i + 1]) els[i + 1].classList.add('_debobigegoNoConcatNext'); + } + } + }; + + onMounted(() => { + scanChild(); + + const observer = new MutationObserver(records => { + scanChild(); + }); + + observer.observe(child.value, { + childList: true, + subtree: false, + attributes: false, + characterData: false, + }); + }); + + return { + child + }; + } +}); +</script> + +<style lang="scss" scoped> +.vrtktovg { + > .main { + > ::v-deep(*):not(._debobigegoNoConcat) { + &:not(._debobigegoNoConcatNext) { + margin: 0; + } + + &:not(:last-child):not(._debobigegoNoConcatPrev) { + &._debobigegoPanel, ._debobigegoPanel { + border-bottom: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + &:not(:first-child):not(._debobigegoNoConcatNext) { + &._debobigegoPanel, ._debobigegoPanel { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/info.vue b/packages/client/src/components/debobigego/info.vue new file mode 100644 index 0000000000..41afb03304 --- /dev/null +++ b/packages/client/src/components/debobigego/info.vue @@ -0,0 +1,47 @@ +<template> +<div class="fzenkabp _debobigegoItem"> + <div class="_debobigegoPanel" :class="{ warn }"> + <i v-if="warn" class="fas fa-exclamation-triangle"></i> + <i v-else class="fas fa-info-circle"></i> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + warn: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.fzenkabp { + > div { + padding: 14px 16px; + font-size: 90%; + background: var(--infoBg); + color: var(--infoFg); + + &.warn { + background: var(--infoWarnBg); + color: var(--infoWarnFg); + } + + > i { + margin-right: 4px; + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/input.vue b/packages/client/src/components/debobigego/input.vue new file mode 100644 index 0000000000..d113f04d27 --- /dev/null +++ b/packages/client/src/components/debobigego/input.vue @@ -0,0 +1,292 @@ +<template> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="ztzhwixg _debobigegoItem" :class="{ inline, disabled }"> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _debobigegoPanel"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> + </div> + </div> + <template #caption><slot name="desc"></slot></template> + + <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import './debobigego.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + FormButton, + }, + props: { + modelValue: { + required: false + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + step: { + required: false + }, + datalist: { + type: Array, + required: false, + }, + inline: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + setup(props, context) { + const { modelValue, type, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const id = Math.random().toString(); // TODO: uuid? + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + const updated = () => { + changed.value = false; + if (type?.value === 'number') { + context.emit('update:modelValue', parseFloat(v.value)); + } else { + context.emit('update:modelValue', v.value); + } + }; + + watch(modelValue.value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + return { + id, + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + focus, + onInput, + onKeydown, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ztzhwixg { + position: relative; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + $height: 48px; + position: relative; + + > input { + display: block; + height: $height; + width: 100%; + margin: 0; + padding: 0 16px; + font: inherit; + font-weight: normal; + font-size: 1em; + line-height: $height; + color: var(--inputText); + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + box-sizing: border-box; + + &[type='file'] { + display: none; + } + } + + > .prefix, + > .suffix { + display: block; + position: absolute; + z-index: 1; + top: 0; + padding: 0 16px; + font-size: 1em; + line-height: $height; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 8px; + } + + > .suffix { + right: 0; + padding-left: 8px; + } + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/key-value-view.vue b/packages/client/src/components/debobigego/key-value-view.vue new file mode 100644 index 0000000000..0e034a2d54 --- /dev/null +++ b/packages/client/src/components/debobigego/key-value-view.vue @@ -0,0 +1,38 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoPanel anocepby"> + <span class="key"><slot name="key"></slot></span> + <span class="value"><slot name="value"></slot></span> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.anocepby { + display: flex; + align-items: center; + padding: 14px var(--debobigegoContentHMargin); + + > .key { + margin-right: 12px; + white-space: nowrap; + } + + > .value { + margin-left: auto; + opacity: 0.7; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } +} +</style> diff --git a/packages/client/src/components/debobigego/link.vue b/packages/client/src/components/debobigego/link.vue new file mode 100644 index 0000000000..885579eadf --- /dev/null +++ b/packages/client/src/components/debobigego/link.vue @@ -0,0 +1,103 @@ +<template> +<div class="qmfkfnzi _debobigegoItem"> + <a class="main _button _debobigegoPanel _debobigegoClickable" :href="to" target="_blank" v-if="external"> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <span class="right"> + <span class="text"><slot name="suffix"></slot></span> + <i class="fas fa-external-link-alt icon"></i> + </span> + </a> + <MkA class="main _button _debobigegoPanel _debobigegoClickable" :class="{ active }" :to="to" :behavior="behavior" v-else> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <span class="right"> + <span class="text"><slot name="suffix"></slot></span> + <i class="fas fa-chevron-right icon"></i> + </span> + </MkA> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + to: { + type: String, + required: true + }, + active: { + type: Boolean, + required: false + }, + external: { + type: Boolean, + required: false + }, + behavior: { + type: String, + required: false, + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.qmfkfnzi { + > .main { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 14px 16px 14px 14px; + + &:hover { + text-decoration: none; + } + + &.active { + color: var(--accent); + background: var(--panelHighlight); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + + &:empty { + display: none; + + & + .text { + padding-left: 4px; + } + } + } + + > .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 12px; + } + + > .right { + margin-left: auto; + opacity: 0.7; + + > .text:not(:empty) { + margin-right: 0.75em; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/object-view.vue b/packages/client/src/components/debobigego/object-view.vue new file mode 100644 index 0000000000..ea79daa915 --- /dev/null +++ b/packages/client/src/components/debobigego/object-view.vue @@ -0,0 +1,102 @@ +<template> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="drooglns _debobigegoItem" :class="{ tall }"> + <div class="input _debobigegoPanel"> + <textarea class="_monospace" + v-model="v" + readonly + :spellcheck="false" + ></textarea> + </div> + </div> + <template #caption><slot name="desc"></slot></template> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, ref, toRefs, watch } from 'vue'; +import * as JSON5 from 'json5'; +import './debobigego.scss'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + }, + props: { + value: { + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + setup(props, context) { + const { value } = toRefs(props); + const v = ref(''); + + watch(() => value, newValue => { + v.value = JSON5.stringify(newValue.value, null, '\t'); + }, { + immediate: true + }); + + return { + v, + }; + } +}); +</script> + +<style lang="scss" scoped> +.drooglns { + position: relative; + + > .input { + position: relative; + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 16px var(--debobigegoContentHMargin); + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 1em; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + tab-size: 2; + white-space: pre; + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/pagination.vue b/packages/client/src/components/debobigego/pagination.vue new file mode 100644 index 0000000000..07012cb759 --- /dev/null +++ b/packages/client/src/components/debobigego/pagination.vue @@ -0,0 +1,42 @@ +<template> +<FormGroup class="uljviswt _debobigegoItem"> + <template #label><slot name="label"></slot></template> + <slot :items="items"></slot> + <div class="empty" v-if="empty" key="_empty_"> + <slot name="empty"></slot> + </div> + <FormButton v-show="more" class="button" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; +import paging from '@/scripts/paging'; + +export default defineComponent({ + components: { + FormButton, + FormGroup, + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + }, +}); +</script> + +<style lang="scss" scoped> +.uljviswt { +} +</style> diff --git a/packages/client/src/components/debobigego/radios.vue b/packages/client/src/components/debobigego/radios.vue new file mode 100644 index 0000000000..b4c5841337 --- /dev/null +++ b/packages/client/src/components/debobigego/radios.vue @@ -0,0 +1,112 @@ +<script lang="ts"> +import { defineComponent, h } from 'vue'; +import MkRadio from '@/components/form/radio.vue'; +import './debobigego.scss'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + modelValue: { + required: false + }, + }, + data() { + return { + value: this.modelValue, + } + }, + watch: { + modelValue() { + this.value = this.modelValue; + }, + value() { + this.$emit('update:modelValue', this.value); + } + }, + render() { + const label = this.$slots.desc(); + let options = this.$slots.default(); + + // なぜかFragmentになることがあるため + if (options.length === 1 && options[0].props == null) options = options[0].children; + + return h('div', { + class: 'cnklmpwm _debobigegoItem' + }, [ + h('div', { + class: '_debobigegoLabel', + }, label), + ...options.map(option => h('button', { + class: '_button _debobigegoPanel _debobigegoClickable', + key: option.key, + onClick: () => this.value = option.props.value, + }, [h('span', { + class: ['check', { checked: this.value === option.props.value }], + }), option.children])) + ]); + } +}); +</script> + +<style lang="scss"> +.cnklmpwm { + > button { + display: block; + width: 100%; + box-sizing: border-box; + padding: 14px 18px; + text-align: left; + + &:not(:first-of-type) { + border-top: none !important; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &:not(:last-of-type) { + border-bottom: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + > .check { + display: inline-block; + vertical-align: bottom; + position: relative; + width: 16px; + height: 16px; + margin-right: 8px; + background: none; + border: 2px solid var(--inputBorder); + border-radius: 100%; + transition: inherit; + + &:after { + content: ""; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: .4s cubic-bezier(.25,.8,.25,1); + } + + &.checked { + border-color: var(--accent); + + &:after { + background-color: var(--accent); + transform: scale(1); + opacity: 1; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/range.vue b/packages/client/src/components/debobigego/range.vue new file mode 100644 index 0000000000..26fb0f37c6 --- /dev/null +++ b/packages/client/src/components/debobigego/range.vue @@ -0,0 +1,122 @@ +<template> +<div class="ifitouly _debobigegoItem" :class="{ focused, disabled }"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="_debobigegoPanel main"> + <input + type="range" + ref="input" + v-model="v" + :disabled="disabled" + :min="min" + :max="max" + :step="step" + @focus="focused = true" + @blur="focused = false" + @input="$emit('update:value', $event.target.value)" + /> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: false, + default: 0 + }, + disabled: { + type: Boolean, + required: false, + default: false + }, + min: { + type: Number, + required: false, + default: 0 + }, + max: { + type: Number, + required: false, + default: 100 + }, + step: { + type: Number, + required: false, + default: 1 + }, + }, + data() { + return { + v: this.value, + focused: false + }; + }, + watch: { + value(v) { + this.v = parseFloat(v); + } + }, +}); +</script> + +<style lang="scss" scoped> +.ifitouly { + position: relative; + + > .main { + padding: 22px 16px; + + > input { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--X10); + height: 4px; + width: 100%; + box-sizing: border-box; + margin: 0; + outline: 0; + border: 0; + border-radius: 7px; + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + box-sizing: content-box; + } + + &::-moz-range-thumb { + -moz-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/select.vue b/packages/client/src/components/debobigego/select.vue new file mode 100644 index 0000000000..7a31371afc --- /dev/null +++ b/packages/client/src/components/debobigego/select.vue @@ -0,0 +1,145 @@ +<template> +<div class="yrtfrpux _debobigegoItem" :class="{ disabled, inline }"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _debobigegoPanel _debobigegoClickable" @click="focus"> + <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> + <select ref="input" + v-model="v" + :required="required" + :disabled="disabled" + @focus="focused = true" + @blur="focused = false" + > + <slot></slot> + </select> + <div class="suffix"> + <i class="fas fa-chevron-down"></i> + </div> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + modelValue: { + required: false + }, + required: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + }; + }, + computed: { + v: { + get() { + return this.modelValue; + }, + set(v) { + this.$emit('update:modelValue', v); + } + }, + }, + methods: { + focus() { + this.$refs.input.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.yrtfrpux { + position: relative; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + display: flex; + position: relative; + + > select { + display: block; + flex: 1; + width: 100%; + padding: 0 16px; + font: inherit; + font-weight: normal; + font-size: 1em; + height: 48px; + background: none; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + appearance: none; + -webkit-appearance: none; + color: var(--fg); + + option, + optgroup { + color: var(--fg); + background: var(--bg); + } + } + + > .prefix, + > .suffix { + display: block; + align-self: center; + justify-self: center; + font-size: 1em; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: block; + min-width: 16px; + } + } + + > .prefix { + padding-right: 4px; + } + + > .suffix { + padding: 0 16px 0 0; + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/suspense.vue b/packages/client/src/components/debobigego/suspense.vue new file mode 100644 index 0000000000..b5ba63b4b5 --- /dev/null +++ b/packages/client/src/components/debobigego/suspense.vue @@ -0,0 +1,101 @@ +<template> +<transition name="fade" mode="out-in"> + <div class="_debobigegoItem" v-if="pending"> + <div class="_debobigegoPanel"> + <MkLoading/> + </div> + </div> + <div v-else-if="resolved" class="_debobigegoItem"> + <slot :result="result"></slot> + </div> + <div class="_debobigegoItem" v-else> + <div class="_debobigegoPanel eiurkvay"> + <div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div> + <MkButton inline @click="retry" class="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType, ref, watch } from 'vue'; +import './debobigego.scss'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + p: { + type: Function as PropType<() => Promise<any>>, + required: true, + } + }, + + setup(props, context) { + const pending = ref(true); + const resolved = ref(false); + const rejected = ref(false); + const result = ref(null); + + const process = () => { + if (props.p == null) { + return; + } + const promise = props.p(); + pending.value = true; + resolved.value = false; + rejected.value = false; + promise.then((_result) => { + pending.value = false; + resolved.value = true; + result.value = _result; + }); + promise.catch(() => { + pending.value = false; + rejected.value = true; + }); + }; + + watch(() => props.p, () => { + process(); + }, { + immediate: true + }); + + const retry = () => { + process(); + }; + + return { + pending, + resolved, + rejected, + result, + retry, + }; + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.eiurkvay { + padding: 16px; + text-align: center; + + > .retry { + margin-top: 16px; + } +} +</style> diff --git a/packages/client/src/components/debobigego/switch.vue b/packages/client/src/components/debobigego/switch.vue new file mode 100644 index 0000000000..9a69e18302 --- /dev/null +++ b/packages/client/src/components/debobigego/switch.vue @@ -0,0 +1,132 @@ +<template> +<div class="ijnpvmgr _debobigegoItem"> + <div class="main _debobigegoPanel _debobigegoClickable" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click.prevent="toggle" + > + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> + <span class="handle"></span> + </span> + <span class="label"> + <span><slot></slot></span> + </span> + </div> + <div class="_debobigegoCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + modelValue: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.modelValue; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:modelValue', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ijnpvmgr { + > .main { + position: relative; + display: flex; + padding: 14px 16px; + cursor: pointer; + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 0; + width: 34px; + height: 22px; + background: var(--switchBg); + outline: none; + border-radius: 999px; + transition: all 0.3s; + cursor: pointer; + + > .handle { + position: absolute; + top: 0; + left: 3px; + bottom: 0; + margin: auto 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 16px; + height: 16px; + background-color: #fff; + pointer-events: none; + } + } + + > .label { + margin-left: 12px; + display: block; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent); + + > .handle { + transform: translateX(12px); + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/textarea.vue b/packages/client/src/components/debobigego/textarea.vue new file mode 100644 index 0000000000..64e8d47126 --- /dev/null +++ b/packages/client/src/components/debobigego/textarea.vue @@ -0,0 +1,161 @@ +<template> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="rivhosbp _debobigegoItem" :class="{ tall, pre }"> + <div class="input _debobigegoPanel"> + <textarea ref="input" :class="{ code, _monospace: code }" + v-model="v" + :required="required" + :readonly="readonly" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="!code" + @input="onInput" + @focus="focused = true" + @blur="focused = false" + ></textarea> + </div> + </div> + <template #caption><slot name="desc"></slot></template> + + <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, ref, toRefs, watch } from 'vue'; +import './debobigego.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + FormButton, + }, + props: { + modelValue: { + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + autocomplete: { + type: String, + required: false + }, + code: { + type: Boolean, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + setup(props, context) { + const { modelValue } = toRefs(props); + const v = ref(modelValue.value); + const changed = ref(false); + const inputEl = ref(null); + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + watch(modelValue.value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + }); + + return { + v, + updated, + changed, + focus, + onInput, + }; + } +}); +</script> + +<style lang="scss" scoped> +.rivhosbp { + position: relative; + + > .input { + position: relative; + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 16px; + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 1em; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + + &.code { + tab-size: 2; + } + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } + + &.pre { + > .input { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/tuple.vue b/packages/client/src/components/debobigego/tuple.vue new file mode 100644 index 0000000000..8a4599fd64 --- /dev/null +++ b/packages/client/src/components/debobigego/tuple.vue @@ -0,0 +1,36 @@ +<template> +<div class="wthhikgt _debobigegoItem" v-size="{ max: [500] }"> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ +}); +</script> + +<style lang="scss" scoped> +.wthhikgt { + position: relative; + display: flex; + + > ::v-deep(*) { + flex: 1; + margin: 0; + + &:not(:last-child) { + margin-right: 16px; + } + } + + &.max-width_500px { + display: block; + + > ::v-deep(*) { + margin: inherit; + } + } +} +</style> diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue new file mode 100644 index 0000000000..90086fd430 --- /dev/null +++ b/packages/client/src/components/dialog.vue @@ -0,0 +1,212 @@ +<template> +<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> + <div class="mk-dialog"> + <div class="icon" v-if="icon"> + <i :class="icon"></i> + </div> + <div class="icon" v-else-if="!input && !select" :class="type"> + <i v-if="type === 'success'" class="fas fa-check"></i> + <i v-else-if="type === 'error'" class="fas fa-times-circle"></i> + <i v-else-if="type === 'warning'" class="fas fa-exclamation-triangle"></i> + <i v-else-if="type === 'info'" class="fas fa-info-circle"></i> + <i v-else-if="type === 'question'" class="fas fa-question-circle"></i> + <i v-else-if="type === 'waiting'" class="fas fa-spinner fa-pulse"></i> + </div> + <header v-if="title"><Mfm :text="title"/></header> + <div class="body" v-if="text"><Mfm :text="text"/></div> + <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></MkInput> + <MkSelect v-if="select" v-model="selectedValue" autofocus> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </template> + <template v-else> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> + </template> + </MkSelect> + <div class="buttons" v-if="(showOkButton || showCancelButton) && !actions"> + <MkButton inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select">{{ (showCancelButton || input || select) ? $ts.ok : $ts.gotIt }}</MkButton> + <MkButton inline @click="cancel" v-if="showCancelButton || input || select">{{ $ts.cancel }}</MkButton> + </div> + <div class="buttons" v-if="actions"> + <MkButton v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</MkButton> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } 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, + }, + + 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 + }, + }, + + emits: ['done', 'closed'], + + 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, + }; + }, + + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + + beforeUnmount() { + document.removeEventListener('keydown', this.onKeydown); + }, + + methods: { + done(canceled, result?) { + this.$emit('done', { canceled, result }); + this.$refs.modal.close(); + }, + + async ok() { + if (!this.showOkButton) return; + + const result = + this.input ? this.inputValue : + this.select ? this.selectedValue : + true; + this.done(false, result); + }, + + cancel() { + this.done(true); + }, + + 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(); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-dialog { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .icon { + font-size: 32px; + + &.success { + color: var(--success); + } + + &.error { + color: var(--error); + } + + &.warning { + color: var(--warn); + } + + > * { + display: block; + margin: 0 auto; + } + + & + header { + margin-top: 16px; + } + } + + > header { + margin: 0 0 8px 0; + font-weight: bold; + font-size: 20px; + + & + .body { + margin-top: 8px; + } + } + + > .body { + margin: 16px 0 0 0; + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } +} +</style> diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue new file mode 100644 index 0000000000..9b6a0c9a0d --- /dev/null +++ b/packages/client/src/components/drive-file-thumbnail.vue @@ -0,0 +1,108 @@ +<template> +<div class="zdjebgpv" ref="thumbnail"> + <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/> + <i v-else-if="is === 'image'" class="fas fa-file-image icon"></i> + <i v-else-if="is === 'video'" class="fas fa-file-video icon"></i> + <i v-else-if="is === 'audio' || is === 'midi'" class="fas fa-music icon"></i> + <i v-else-if="is === 'csv'" class="fas fa-file-csv icon"></i> + <i v-else-if="is === 'pdf'" class="fas fa-file-pdf icon"></i> + <i v-else-if="is === 'textfile'" class="fas fa-file-alt icon"></i> + <i v-else-if="is === 'archive'" class="fas fa-file-archive icon"></i> + <i v-else class="fas fa-file icon"></i> + + <i v-if="isThumbnailAvailable && is === 'video'" class="fas fa-film icon-sub"></i> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import { ColdDeviceStorage } from '@/store'; + +export default defineComponent({ + components: { + ImgWithBlurhash + }, + props: { + file: { + type: Object, + required: true + }, + fit: { + type: String, + required: false, + default: 'cover' + }, + }, + data() { + return { + isContextmenuShowing: false, + isDragging: false, + + }; + }, + computed: { + is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' { + if (this.file.type.startsWith('image/')) return 'image'; + if (this.file.type.startsWith('video/')) return 'video'; + if (this.file.type === 'audio/midi') return 'midi'; + if (this.file.type.startsWith('audio/')) return 'audio'; + if (this.file.type.endsWith('/csv')) return 'csv'; + if (this.file.type.endsWith('/pdf')) return 'pdf'; + if (this.file.type.startsWith('text/')) return 'textfile'; + if ([ + "application/zip", + "application/x-cpio", + "application/x-bzip", + "application/x-bzip2", + "application/java-archive", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + "application/x-7z-compressed" + ].some(e => e === this.file.type)) return 'archive'; + return 'unknown'; + }, + isThumbnailAvailable(): boolean { + return this.file.thumbnailUrl + ? (this.is === 'image' || this.is === 'video') + : false; + }, + }, + mounted() { + const audioTag = this.$refs.volumectrl as HTMLAudioElement; + if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); + }, + methods: { + volumechange() { + const audioTag = this.$refs.volumectrl as HTMLAudioElement; + ColdDeviceStorage.set('mediaVolume', audioTag.volume); + } + } +}); +</script> + +<style lang="scss" scoped> +.zdjebgpv { + position: relative; + + > .icon-sub { + position: absolute; + width: 30%; + height: auto; + margin: 0; + right: 4%; + bottom: 4%; + } + + > * { + margin: auto; + } + + > .icon { + pointer-events: none; + height: 65%; + width: 65%; + } +} +</style> diff --git a/packages/client/src/components/drive-select-dialog.vue b/packages/client/src/components/drive-select-dialog.vue new file mode 100644 index 0000000000..f9a4025452 --- /dev/null +++ b/packages/client/src/components/drive-select-dialog.vue @@ -0,0 +1,70 @@ +<template> +<XModalWindow ref="dialog" + :width="800" + :height="500" + :with-ok-button="true" + :ok-button-disabled="(type === 'file') && (selected.length === 0)" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <template #header> + {{ multiple ? ((type === 'file') ? $ts.selectFiles : $ts.selectFolders) : ((type === 'file') ? $ts.selectFile : $ts.selectFolder) }} + <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> + </template> + <XDrive :multiple="multiple" @changeSelection="onChangeSelection" @selected="ok()" :select="type"/> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XDrive from './drive.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + XDrive, + XModalWindow, + }, + + props: { + type: { + type: String, + required: false, + default: 'file' + }, + multiple: { + type: Boolean, + default: false + } + }, + + emits: ['done', 'closed'], + + data() { + return { + selected: [] + }; + }, + + methods: { + ok() { + this.$emit('done', this.selected); + this.$refs.dialog.close(); + }, + + cancel() { + this.$emit('done'); + this.$refs.dialog.close(); + }, + + onChangeSelection(xs) { + this.selected = xs; + }, + + number + } +}); +</script> diff --git a/packages/client/src/components/drive-window.vue b/packages/client/src/components/drive-window.vue new file mode 100644 index 0000000000..43f07ebe76 --- /dev/null +++ b/packages/client/src/components/drive-window.vue @@ -0,0 +1,44 @@ +<template> +<XWindow ref="window" + :initial-width="800" + :initial-height="500" + :can-resize="true" + @closed="$emit('closed')" +> + <template #header> + {{ $ts.drive }} + </template> + <XDrive :initial-folder="initialFolder"/> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XDrive from './drive.vue'; +import XWindow from '@/components/ui/window.vue'; + +export default defineComponent({ + components: { + XDrive, + XWindow, + }, + + props: { + initialFolder: { + type: Object, + required: false + }, + }, + + emits: ['closed'], + + data() { + return { + }; + }, + + methods: { + + } +}); +</script> diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/drive.file.vue new file mode 100644 index 0000000000..86f4ee0f8d --- /dev/null +++ b/packages/client/src/components/drive.file.vue @@ -0,0 +1,374 @@ +<template> +<div class="ncvczrfv" + :class="{ isSelected }" + @click="onClick" + @contextmenu.stop="onContextmenu" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + :title="title" +> + <div class="label" v-if="$i.avatarId == file.id"> + <img src="/client-assets/label.svg"/> + <p>{{ $ts.avatar }}</p> + </div> + <div class="label" v-if="$i.bannerId == file.id"> + <img src="/client-assets/label.svg"/> + <p>{{ $ts.banner }}</p> + </div> + <div class="label red" v-if="file.isSensitive"> + <img src="/client-assets/label-red.svg"/> + <p>{{ $ts.nsfw }}</p> + </div> + + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkDriveFileThumbnail + }, + + props: { + file: { + type: Object, + required: true, + }, + isSelected: { + type: Boolean, + required: false, + default: false, + }, + selectMode: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['chosen'], + + data() { + return { + isDragging: false + }; + }, + + computed: { + // TODO: parentへの参照を無くす + browser(): any { + return this.$parent; + }, + title(): string { + return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`; + } + }, + + methods: { + getMenu() { + return [{ + text: this.$ts.rename, + icon: 'fas fa-i-cursor', + action: this.rename + }, { + text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, + icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', + action: this.toggleSensitive + }, { + text: this.$ts.describeFile, + icon: 'fas fa-i-cursor', + action: this.describe + }, null, { + text: this.$ts.copyUrl, + icon: 'fas fa-link', + action: this.copyUrl + }, { + type: 'a', + href: this.file.url, + target: '_blank', + text: this.$ts.download, + icon: 'fas fa-download', + download: this.file.name + }, null, { + text: this.$ts.delete, + icon: 'fas fa-trash-alt', + danger: true, + action: this.deleteFile + }]; + }, + + onClick(ev) { + if (this.selectMode) { + this.$emit('chosen', this.file); + } else { + os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); + } + }, + + onContextmenu(e) { + os.contextMenu(this.getMenu(), e); + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + rename() { + os.dialog({ + title: this.$ts.renameFile, + input: { + placeholder: this.$ts.inputNewFileName, + default: this.file.name, + allowEmpty: false + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/files/update', { + fileId: this.file.id, + name: name + }); + }); + }, + + describe() { + os.popup(import('@/components/media-caption.vue'), { + title: this.$ts.describeFile, + input: { + placeholder: this.$ts.inputNewDescription, + default: this.file.comment !== null ? this.file.comment : '', + }, + image: this.file + }, { + done: result => { + if (!result || result.canceled) return; + let comment = result.result; + os.api('drive/files/update', { + fileId: this.file.id, + comment: comment.length == 0 ? null : comment + }); + } + }, 'closed'); + }, + + toggleSensitive() { + os.api('drive/files/update', { + fileId: this.file.id, + isSensitive: !this.file.isSensitive + }); + }, + + copyUrl() { + copyToClipboard(this.file.url); + os.success(); + }, + + setAsAvatar() { + os.updateAvatar(this.file); + }, + + setAsBanner() { + os.updateBanner(this.file); + }, + + addApp() { + alert('not implemented yet'); + }, + + async deleteFile() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('driveFileDeleteConfirm', { name: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + os.api('drive/files/delete', { + fileId: this.file.id + }); + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> +.ncvczrfv { + position: relative; + padding: 8px 0 0 0; + min-height: 180px; + border-radius: 4px; + + &, * { + cursor: pointer; + } + + > * { + pointer-events: none; + } + + &:hover { + background: rgba(#000, 0.05); + + > .label { + &:before, + &:after { + background: #0b65a5; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + } + } + + &:active { + background: rgba(#000, 0.1); + + > .label { + &:before, + &:after { + background: #0b588c; + } + + &.red { + &:before, + &:after { + background: #ce2212; + } + } + } + } + + &.isSelected { + background: var(--accent); + + &:hover { + background: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + } + + > .label { + &:before, + &:after { + display: none; + } + } + + > .name { + color: #fff; + } + + > .thumbnail { + color: #fff; + } + } + + > .label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + + &:before, + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1; + background: #0c7ac9; + } + + &:before { + top: 0; + left: 57px; + width: 28px; + height: 8px; + } + + &:after { + top: 57px; + left: 0; + width: 8px; + height: 28px; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + + > img { + position: absolute; + z-index: 2; + top: 0; + left: 0; + } + + > p { + position: absolute; + z-index: 3; + top: 19px; + left: -28px; + width: 120px; + margin: 0; + text-align: center; + line-height: 28px; + color: #fff; + transform: rotate(-45deg); + } + } + + > .thumbnail { + width: 110px; + height: 110px; + margin: auto; + } + + > .name { + display: block; + margin: 4px 0 0 0; + font-size: 0.8em; + text-align: center; + word-break: break-all; + color: var(--fg); + overflow: hidden; + + > .ext { + opacity: 0.5; + } + } +} +</style> diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue new file mode 100644 index 0000000000..91e27cc8a1 --- /dev/null +++ b/packages/client/src/components/drive.folder.vue @@ -0,0 +1,326 @@ +<template> +<div class="rghtznwe" + :class="{ draghover }" + @click="onClick" + @contextmenu.stop="onContextmenu" + @mouseover="onMouseover" + @mouseout="onMouseout" + @dragover.prevent.stop="onDragover" + @dragenter.prevent="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + :title="title" +> + <p class="name"> + <template v-if="hover"><i class="fas fa-folder-open fa-fw"></i></template> + <template v-if="!hover"><i class="fas fa-folder fa-fw"></i></template> + {{ folder.name }} + </p> + <p class="upload" v-if="$store.state.uploadFolder == folder.id"> + {{ $ts.uploadFolder }} + </p> + <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + folder: { + type: Object, + required: true, + }, + isSelected: { + type: Boolean, + required: false, + default: false, + }, + selectMode: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['chosen'], + + data() { + return { + hover: false, + draghover: false, + isDragging: false, + }; + }, + + computed: { + browser(): any { + return this.$parent; + }, + title(): string { + return this.folder.name; + } + }, + + methods: { + checkboxClicked(e) { + this.$emit('chosen', this.folder); + }, + + onClick() { + this.browser.move(this.folder); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false + }, + + onDragover(e) { + // 自分自身がドラッグされている場合 + if (this.isDragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDragenter() { + if (!this.isDragging) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.browser.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: this.folder.id + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.id == this.folder.id) return; + + this.browser.removeFolder(folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder.id + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.dialog({ + title: this.$ts.unableToProcess, + text: this.$ts.circularReferenceFolder + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + } + }); + } + //#endregion + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(this.folder)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend() { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + go() { + this.browser.move(this.folder.id); + }, + + newWindow() { + this.browser.newWindow(this.folder); + }, + + rename() { + os.dialog({ + title: this.$ts.renameFolder, + input: { + placeholder: this.$ts.inputNewFolderName, + default: this.folder.name + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: this.folder.id, + name: name + }); + }); + }, + + deleteFolder() { + os.api('drive/folders/delete', { + folderId: this.folder.id + }).then(() => { + if (this.$store.state.uploadFolder === this.folder.id) { + this.$store.set('uploadFolder', null); + } + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + os.dialog({ + type: 'error', + title: this.$ts.unableToDelete, + text: this.$ts.hasChildFilesOrFolders + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.unableToDelete + }); + } + }); + }, + + setAsUploadFolder() { + this.$store.set('uploadFolder', this.folder.id); + }, + + onContextmenu(e) { + os.contextMenu([{ + text: this.$ts.openInWindow, + icon: 'fas fa-window-restore', + action: () => { + os.popup(import('./drive-window.vue'), { + initialFolder: this.folder + }, { + }, 'closed'); + } + }, null, { + text: this.$ts.rename, + icon: 'fas fa-i-cursor', + action: this.rename + }, null, { + text: this.$ts.delete, + icon: 'fas fa-trash-alt', + danger: true, + action: this.deleteFolder + }], e); + }, + } +}); +</script> + +<style lang="scss" scoped> +.rghtznwe { + position: relative; + padding: 8px; + height: 64px; + background: var(--driveFolderBg); + border-radius: 4px; + + &, * { + cursor: pointer; + } + + *:not(.checkbox) { + pointer-events: none; + } + + > .checkbox { + position: absolute; + bottom: 8px; + right: 8px; + width: 16px; + height: 16px; + background: #fff; + border: solid 1px #000; + + &.checked { + background: var(--accent); + } + } + + &.draghover { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + border: 2px dashed var(--focus); + border-radius: 4px; + } + } + + > .name { + margin: 0; + font-size: 0.9em; + color: var(--desktopDriveFolderFg); + + > i { + margin-right: 4px; + margin-left: 2px; + text-align: left; + } + } + + > .upload { + margin: 4px 4px; + font-size: 0.8em; + text-align: right; + color: var(--desktopDriveFolderFg); + } +} +</style> diff --git a/packages/client/src/components/drive.nav-folder.vue b/packages/client/src/components/drive.nav-folder.vue new file mode 100644 index 0000000000..4f0e6ce0e9 --- /dev/null +++ b/packages/client/src/components/drive.nav-folder.vue @@ -0,0 +1,135 @@ +<template> +<div class="drylbebk" + :class="{ draghover }" + @click="onClick" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <i v-if="folder == null" class="fas fa-cloud"></i> + <span>{{ folder == null ? $ts.drive : folder.name }}</span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + folder: { + type: Object, + required: false, + } + }, + + data() { + return { + hover: false, + draghover: false, + }; + }, + + computed: { + browser(): any { + return this.$parent; + } + }, + + methods: { + onClick() { + this.browser.move(this.folder); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false; + }, + + onDragover(e) { + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (this.folder == null && this.browser.folder == null) { + e.dataTransfer.dropEffect = 'none'; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter() { + if (this.folder || this.browser.folder) this.draghover = true; + }, + + onDragleave() { + if (this.folder || this.browser.folder) this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.browser.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return; + this.browser.removeFolder(folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }); + } + //#endregion + } + } +}); +</script> + +<style lang="scss" scoped> +.drylbebk { + > * { + pointer-events: none; + } + + &.draghover { + background: #eee; + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue new file mode 100644 index 0000000000..2b72a0a1c6 --- /dev/null +++ b/packages/client/src/components/drive.vue @@ -0,0 +1,784 @@ +<template> +<div class="yfudmmck"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <XNavFolder :class="{ current: folder == null }"/> + <template v-for="f in hierarchyFolders"> + <span class="separator"><i class="fas fa-angle-right"></i></span> + <XNavFolder :folder="f"/> + </template> + <span class="separator" v-if="folder != null"><i class="fas fa-angle-right"></i></span> + <span class="folder current" v-if="folder != null">{{ folder.name }}</span> + </div> + <button @click="showMenu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button> + </nav> + <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" + ref="main" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.stop="onContextmenu" + > + <div class="contents" ref="contents"> + <div class="folders" ref="foldersContainer" v-show="folders.length > 0"> + <XFolder v-for="(f, i) in folders" :key="f.id" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" v-anim="i"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="(n, i) in 16" :key="i"></div> + <MkButton ref="moreFolders" v-if="moreFolders">{{ $ts.loadMore }}</MkButton> + </div> + <div class="files" ref="filesContainer" v-show="files.length > 0"> + <XFile v-for="(file, i) in files" :key="file.id" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" v-anim="i"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="(n, i) in 16" :key="i"></div> + <MkButton ref="loadMoreFiles" @click="fetchMoreFiles" v-show="moreFiles">{{ $ts.loadMore }}</MkButton> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="draghover">{{ $t('empty-draghover') }}</p> + <p v-if="!draghover && folder == null"><strong>{{ $ts.emptyDrive }}</strong><br/>{{ $t('empty-drive-description') }}</p> + <p v-if="!draghover && folder != null">{{ $ts.emptyFolder }}</p> + </div> + </div> + <MkLoading v-if="fetching"/> + </div> + <div class="dropzone" v-if="draghover"></div> + <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import XNavFolder from './drive.nav-folder.vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import MkButton from './ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNavFolder, + XFolder, + XFile, + MkButton, + }, + + props: { + initialFolder: { + type: Object, + required: false + }, + type: { + type: String, + required: false, + default: undefined + }, + multiple: { + type: Boolean, + required: false, + default: false + }, + select: { + type: String, + required: false, + default: null + } + }, + + emits: ['selected', 'change-selection', 'move-root', 'cd', 'open-folder'], + + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + selectedFolders: [], + uploadings: os.uploads, + connection: null, + + /** + * ドロップされようとしているか + */ + draghover: false, + + /** + * 自信の所有するアイテムがドラッグをスタートさせたか + * (自分自身の階層にドロップできないようにするためのフラグ) + */ + isDragSource: false, + + fetching: true, + + ilFilesObserver: new IntersectionObserver( + (entries) => entries.some((entry) => entry.isIntersecting) + && !this.fetching && this.moreFiles && + this.fetchMoreFiles() + ), + moreFilesElement: null as Element, + + }; + }, + + watch: { + folder() { + this.$emit('cd', this.folder); + } + }, + + mounted() { + if (this.$store.state.enableInfiniteScroll && this.$refs.loadMoreFiles) { + this.$nextTick(() => { + this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) + }); + } + + this.connection = markRaw(os.stream.useChannel('drive')); + + this.connection.on('fileCreated', this.onStreamDriveFileCreated); + this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); + this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); + this.connection.on('folderCreated', this.onStreamDriveFolderCreated); + this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); + this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted); + + if (this.initialFolder) { + this.move(this.initialFolder); + } else { + this.fetch(); + } + }, + + activated() { + if (this.$store.state.enableInfiniteScroll) { + this.$nextTick(() => { + this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) + }); + } + }, + + beforeUnmount() { + this.connection.dispose(); + this.ilFilesObserver.disconnect(); + }, + + methods: { + onStreamDriveFileCreated(file) { + this.addFile(file, true); + }, + + onStreamDriveFileUpdated(file) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFileDeleted(fileId) { + this.removeFile(fileId); + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + onStreamDriveFolderDeleted(folderId) { + this.removeFolder(folderId); + }, + + onDragover(e): any { + // ドラッグ元が自分自身の所有するアイテムだったら + if (this.isDragSource) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter(e) { + if (!this.isDragSource) this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): any { + this.draghover = false; + + // ドロップされてきたものがファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + if (this.files.some(f => f.id == file.id)) return; + this.removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return false; + if (this.folders.some(f => f.id == folder.id)) return false; + this.removeFolder(folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.dialog({ + title: this.$ts.unableToProcess, + text: this.$ts.circularReferenceFolder + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + } + }); + } + //#endregion + }, + + selectLocalFile() { + (this.$refs.fileInput as any).click(); + }, + + urlUpload() { + os.dialog({ + title: this.$ts.uploadFromUrl, + input: { + placeholder: this.$ts.uploadFromUrlDescription + } + }).then(({ canceled, result: url }) => { + if (canceled) return; + os.api('drive/files/upload-from-url', { + url: url, + folderId: this.folder ? this.folder.id : undefined + }); + + os.dialog({ + title: this.$ts.uploadFromUrlRequested, + text: this.$ts.uploadFromUrlMayTakeTime + }); + }); + }, + + createFolder() { + os.dialog({ + title: this.$ts.createFolder, + input: { + placeholder: this.$ts.folderName + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/create', { + name: name, + parentId: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }); + }, + + renameFolder(folder) { + os.dialog({ + title: this.$ts.renameFolder, + input: { + placeholder: this.$ts.inputNewFolderName, + default: folder.name + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: folder.id, + name: name + }).then(folder => { + // FIXME: 画面を更新するために自分自身に移動 + this.move(folder); + }); + }); + }, + + deleteFolder(folder) { + os.api('drive/folders/delete', { + folderId: folder.id + }).then(() => { + // 削除時に親フォルダに移動 + this.move(folder.parentId); + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + os.dialog({ + type: 'error', + title: this.$ts.unableToDelete, + text: this.$ts.hasChildFilesOrFolders + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.unableToDelete + }); + } + }); + }, + + onChangeFileInput() { + for (const file of Array.from((this.$refs.fileInput as any).files)) { + this.upload(file, this.folder); + } + }, + + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + os.upload(file, folder).then(res => { + this.addFile(res, true); + }); + }, + + chooseFile(file) { + const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + if (isAlreadySelected) { + this.$emit('selected', file); + } else { + this.selectedFiles = [file]; + this.$emit('change-selection', [file]); + } + } + }, + + chooseFolder(folder) { + const isAlreadySelected = this.selectedFolders.some(f => f.id == folder.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFolders = this.selectedFolders.filter(f => f.id != folder.id); + } else { + this.selectedFolders.push(folder); + } + this.$emit('change-selection', this.selectedFolders); + } else { + if (isAlreadySelected) { + this.$emit('selected', folder); + } else { + this.selectedFolders = [folder]; + this.$emit('change-selection', [folder]); + } + } + }, + + move(target) { + if (target == null) { + this.goRoot(); + return; + } else if (typeof target == 'object') { + target = target.id; + } + + this.fetching = true; + + os.api('drive/folders/show', { + folderId: target + }).then(folder => { + this.folder = folder; + this.hierarchyFolders = []; + + const dive = folder => { + this.hierarchyFolders.unshift(folder); + if (folder.parent) dive(folder.parent); + }; + + if (folder.parent) dive(folder.parent); + + this.$emit('open-folder', folder); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) return; + + if (this.folders.some(f => f.id == folder.id)) { + const exist = this.folders.map(f => f.id).indexOf(folder.id); + this.folders[exist] = folder; + return; + } + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + this.files[exist] = file; + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + + appendFolder(folder) { + this.addFolder(folder); + }, + + prependFile(file) { + this.addFile(file, true); + }, + + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot() { + // 既にrootにいるなら何もしない + if (this.folder == null) return; + + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root'); + this.fetch(); + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 30; + const filesMax = 30; + + // フォルダ一覧取得 + os.api('drive/folders', { + folderId: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + os.api('drive/files', { + folderId: this.folder ? this.folder.id : null, + type: this.type, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + for (const x of fetchedFolders) this.appendFolder(x); + for (const x of fetchedFiles) this.appendFile(x); + this.fetching = false; + } else { + flag = true; + } + }; + }, + + fetchMoreFiles() { + this.fetching = true; + + const max = 30; + + // ファイル一覧取得 + os.api('drive/files', { + folderId: this.folder ? this.folder.id : null, + type: this.type, + untilId: this.files[this.files.length - 1].id, + limit: max + 1 + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + for (const x of files) this.appendFile(x); + this.fetching = false; + }); + }, + + getMenu() { + return [{ + text: this.$ts.addFile, + type: 'label' + }, { + text: this.$ts.upload, + icon: 'fas fa-upload', + action: () => { this.selectLocalFile(); } + }, { + text: this.$ts.fromUrl, + icon: 'fas fa-link', + action: () => { this.urlUpload(); } + }, null, { + text: this.folder ? this.folder.name : this.$ts.drive, + type: 'label' + }, this.folder ? { + text: this.$ts.renameFolder, + icon: 'fas fa-i-cursor', + action: () => { this.renameFolder(this.folder); } + } : undefined, this.folder ? { + text: this.$ts.deleteFolder, + icon: 'fas fa-trash-alt', + action: () => { this.deleteFolder(this.folder); } + } : undefined, { + text: this.$ts.createFolder, + icon: 'fas fa-folder-plus', + action: () => { this.createFolder(); } + }]; + }, + + showMenu(ev) { + os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); + }, + + onContextmenu(ev) { + os.contextMenu(this.getMenu(), ev); + }, + } +}); +</script> + +<style lang="scss" scoped> +.yfudmmck { + display: flex; + flex-direction: column; + height: 100%; + + > nav { + display: flex; + z-index: 2; + width: 100%; + padding: 0 8px; + box-sizing: border-box; + overflow: auto; + font-size: 0.9em; + box-shadow: 0 1px 0 var(--divider); + + &, * { + user-select: none; + } + + > .path { + display: inline-block; + vertical-align: bottom; + line-height: 38px; + white-space: nowrap; + + > * { + display: inline-block; + margin: 0; + padding: 0 8px; + line-height: 38px; + cursor: pointer; + + * { + pointer-events: none; + } + + &:hover { + text-decoration: underline; + } + + &.current { + font-weight: bold; + cursor: default; + + &:hover { + text-decoration: none; + } + } + + &.separator { + margin: 0; + padding: 0; + opacity: 0.5; + cursor: default; + + > i { + margin: 0; + } + } + } + } + + > .menu { + margin-left: auto; + } + } + + > .main { + flex: 1; + overflow: auto; + padding: var(--margin); + + &, * { + user-select: none; + } + + &.fetching { + cursor: wait !important; + + * { + pointer-events: none; + } + + > .contents { + opacity: 0.5; + } + } + + &.uploading { + height: calc(100% - 38px - 100px); + } + + > .contents { + + > .folders, + > .files { + display: flex; + flex-wrap: wrap; + + > .folder, + > .file { + flex-grow: 1; + width: 128px; + margin: 4px; + box-sizing: border-box; + } + + > .padding { + flex-grow: 1; + pointer-events: none; + width: 128px + 8px; + } + } + + > .empty { + padding: 16px; + text-align: center; + pointer-events: none; + opacity: 0.5; + + > p { + margin: 0; + } + } + } + } + + > .dropzone { + position: absolute; + left: 0; + top: 38px; + width: 100%; + height: calc(100% - 38px); + border: dashed 2px var(--focus); + pointer-events: none; + } + + > input { + display: none; + } +} +</style> diff --git a/packages/client/src/components/emoji-picker-dialog.vue b/packages/client/src/components/emoji-picker-dialog.vue new file mode 100644 index 0000000000..1d48bbb8a3 --- /dev/null +++ b/packages/client/src/components/emoji-picker-dialog.vue @@ -0,0 +1,76 @@ +<template> +<MkPopup ref="popup" :manual-showing="manualShowing" :src="src" :front="true" @click="$refs.popup.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')" #default="{point}"> + <MkEmojiPicker class="ryghynhb _popup _shadow" :class="{ pointer: point === 'top' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen" ref="picker"/> +</MkPopup> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkPopup from '@/components/ui/popup.vue'; +import MkEmojiPicker from '@/components/emoji-picker.vue'; + +export default defineComponent({ + components: { + MkPopup, + MkEmojiPicker, + }, + + props: { + manualShowing: { + type: Boolean, + required: false, + default: null, + }, + src: { + required: false + }, + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['done', 'close', 'closed'], + + data() { + return { + + }; + }, + + methods: { + chosen(emoji: any) { + this.$emit('done', emoji); + this.$refs.popup.close(); + }, + + opening() { + this.$refs.picker.reset(); + this.$refs.picker.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.ryghynhb { + &.pointer { + &:before { + --size: 8px; + content: ''; + display: block; + position: absolute; + top: calc(0px - (var(--size) * 2)); + left: 0; + right: 0; + width: 0; + margin: auto; + border: solid var(--size) transparent; + border-bottom-color: var(--popup); + } + } +} +</style> diff --git a/packages/client/src/components/emoji-picker-window.vue b/packages/client/src/components/emoji-picker-window.vue new file mode 100644 index 0000000000..0ffa0c1187 --- /dev/null +++ b/packages/client/src/components/emoji-picker-window.vue @@ -0,0 +1,197 @@ +<template> +<MkWindow ref="window" + :initial-width="null" + :initial-height="null" + :can-resize="false" + :mini="true" + :front="true" + @closed="$emit('closed')" +> + <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> +</MkWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkWindow from '@/components/ui/window.vue'; +import MkEmojiPicker from '@/components/emoji-picker.vue'; + +export default defineComponent({ + components: { + MkWindow, + MkEmojiPicker, + }, + + props: { + src: { + required: false + }, + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['chosen', 'closed'], + + data() { + return { + + }; + }, + + methods: { + chosen(emoji: any) { + this.$emit('chosen', emoji); + }, + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + --eachSize: 40px; + + display: flex; + flex-direction: column; + contain: content; + + &.big { + --eachSize: 44px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + } + + &.h1 { + --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .emojis { + height: var(--height); + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > .index { + min-height: var(--height); + position: relative; + border-bottom: solid 0.5px var(--divider); + + > .arrow { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 16px 0; + text-align: center; + opacity: 0.5; + pointer-events: none; + } + } + + section { + > header { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + font-size: 12px; + } + + > div { + padding: $pad; + + > button { + position: relative; + padding: 0; + width: var(--eachSize); + height: var(--eachSize); + border-radius: 4px; + + &:focus-visible { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > * { + font-size: 24px; + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 0.5px var(--divider); + + &:empty { + display: none; + } + } + + &.unicode { + min-height: 384px; + } + + &.custom { + min-height: 64px; + } + } + } +} +</style> diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/emoji-picker.section.vue new file mode 100644 index 0000000000..2401eca2a5 --- /dev/null +++ b/packages/client/src/components/emoji-picker.section.vue @@ -0,0 +1,50 @@ +<template> +<section> + <header class="_acrylic" @click="shown = !shown"> + <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }}) + </header> + <div v-if="shown"> + <button v-for="emoji in emojis" + class="_button" + @click="chosen(emoji, $event)" + :key="emoji" + > + <MkEmoji :emoji="emoji" :normal="true"/> + </button> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; + +export default defineComponent({ + props: { + emojis: { + required: true, + }, + initialShown: { + required: false + } + }, + + emits: ['chosen'], + + data() { + return { + getStaticImageUrl, + shown: this.initialShown, + }; + }, + + methods: { + chosen(emoji: any, ev) { + this.$parent.chosen(emoji, ev); + }, + } +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue new file mode 100644 index 0000000000..015e201269 --- /dev/null +++ b/packages/client/src/components/emoji-picker.vue @@ -0,0 +1,501 @@ +<template> +<div class="omfetrab" :class="['w' + width, 'h' + height, { big }]"> + <input ref="search" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" v-model.trim="q" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()"> + <div class="emojis" ref="emojis"> + <section class="result"> + <div v-if="searchResultCustom.length > 0"> + <button v-for="emoji in searchResultCustom" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji" + tabindex="0" + > + <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> + <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + <div v-if="searchResultUnicode.length > 0"> + <button v-for="emoji in searchResultUnicode" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji.name" + tabindex="0" + > + <MkEmoji :emoji="emoji.char"/> + </button> + </div> + </section> + + <div class="index" v-if="tab === 'index'"> + <section v-if="showPinned"> + <div> + <button v-for="emoji in pinned" + class="_button" + @click="chosen(emoji, $event)" + tabindex="0" + :key="emoji" + > + <MkEmoji :emoji="emoji" :normal="true"/> + </button> + </div> + </section> + + <section> + <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ $ts.recentUsed }}</header> + <div> + <button v-for="emoji in $store.state.recentlyUsedEmojis" + class="_button" + @click="chosen(emoji, $event)" + :key="emoji" + > + <MkEmoji :emoji="emoji" :normal="true"/> + </button> + </div> + </section> + </div> + <div> + <header class="_acrylic">{{ $ts.customEmojis }}</header> + <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')">{{ category || $ts.other }}</XSection> + </div> + <div> + <header class="_acrylic">{{ $ts.emoji }}</header> + <XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)">{{ category }}</XSection> + </div> + </div> + <div class="tabs"> + <button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="fas fa-asterisk fa-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="fas fa-laugh fa-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="fas fa-leaf fa-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="fas fa-hashtag fa-fw"></i></button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { emojilist } from '@/scripts/emojilist'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import Particle from '@/components/particle.vue'; +import * as os from '@/os'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import { isMobile } from '@/scripts/is-mobile'; +import { emojiCategories } from '@/instance'; +import XSection from './emoji-picker.section.vue'; + +export default defineComponent({ + components: { + XSection + }, + + props: { + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['chosen'], + + data() { + return { + emojilist: markRaw(emojilist), + getStaticImageUrl, + pinned: this.$store.reactiveState.reactions, + width: this.asReactionPicker ? this.$store.state.reactionPickerWidth : 3, + height: this.asReactionPicker ? this.$store.state.reactionPickerHeight : 2, + big: this.asReactionPicker ? isDeviceTouch : false, + customEmojiCategories: emojiCategories, + customEmojis: this.$instance.emojis, + q: null, + searchResultCustom: [], + searchResultUnicode: [], + tab: 'index', + categories: ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'], + }; + }, + + watch: { + q() { + this.$refs.emojis.scrollTop = 0; + + if (this.q == null || this.q === '') { + this.searchResultCustom = []; + this.searchResultUnicode = []; + return; + } + + const q = this.q.replace(/:/g, ''); + + const searchCustom = () => { + const max = 8; + const emojis = this.customEmojis; + const matches = new Set(); + + const exactMatch = emojis.find(e => e.name === q); + if (exactMatch) matches.add(exactMatch); + + if (q.includes(' ')) { // AND検索 + const keywords = q.split(' '); + + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.startsWith(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.includes(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } + + return matches; + }; + + const searchUnicode = () => { + const max = 8; + const emojis = this.emojilist; + const matches = new Set(); + + const exactMatch = emojis.find(e => e.name === q); + if (exactMatch) matches.add(exactMatch); + + if (q.includes(' ')) { // AND検索 + const keywords = q.split(' '); + + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.startsWith(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.includes(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } + + return matches; + }; + + this.searchResultCustom = Array.from(searchCustom()); + this.searchResultUnicode = Array.from(searchUnicode()); + } + }, + + mounted() { + this.focus(); + }, + + methods: { + focus() { + if (!isMobile && !isDeviceTouch) { + this.$refs.search.focus({ + preventScroll: true + }); + } + }, + + reset() { + this.$refs.emojis.scrollTop = 0; + this.q = ''; + }, + + getKey(emoji: any) { + return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`); + }, + + chosen(emoji: any, ev) { + if (ev) { + const el = ev.currentTarget || ev.target; + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.clientWidth / 2); + const y = rect.top + (el.clientHeight / 2); + os.popup(Particle, { x, y }, {}, 'end'); + } + + const key = this.getKey(emoji); + this.$emit('chosen', key); + + // 最近使った絵文字更新 + if (!this.pinned.includes(key)) { + let recents = this.$store.state.recentlyUsedEmojis; + recents = recents.filter((e: any) => e !== key); + recents.unshift(key); + this.$store.set('recentlyUsedEmojis', recents.splice(0, 32)); + } + }, + + paste(event) { + const paste = (event.clipboardData || window.clipboardData).getData('text'); + if (this.done(paste)) { + event.preventDefault(); + } + }, + + done(query) { + if (query == null) query = this.q; + if (query == null) return; + const q = query.replace(/:/g, ''); + const exactMatchCustom = this.customEmojis.find(e => e.name === q); + if (exactMatchCustom) { + this.chosen(exactMatchCustom); + return true; + } + const exactMatchUnicode = this.emojilist.find(e => e.char === q || e.name === q); + if (exactMatchUnicode) { + this.chosen(exactMatchUnicode); + return true; + } + if (this.searchResultCustom.length > 0) { + this.chosen(this.searchResultCustom[0]); + return true; + } + if (this.searchResultUnicode.length > 0) { + this.chosen(this.searchResultUnicode[0]); + return true; + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + --eachSize: 40px; + + display: flex; + flex-direction: column; + + &.big { + --eachSize: 44px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + } + + &.h1 { + --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .tabs { + display: flex; + display: none; + + > .tab { + flex: 1; + height: 38px; + border-top: solid 0.5px var(--divider); + + &.active { + border-top: solid 1px var(--accent); + color: var(--accent); + } + } + } + + > .emojis { + height: var(--height); + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > div { + &:not(.index) { + padding: 4px 0 8px 0; + border-top: solid 0.5px var(--divider); + } + + > header { + /*position: sticky; + top: 0; + left: 0;*/ + height: 32px; + line-height: 32px; + z-index: 2; + padding: 0 8px; + font-size: 12px; + } + } + + ::v-deep(section) { + > header { + position: sticky; + top: 0; + left: 0; + height: 32px; + line-height: 32px; + z-index: 1; + padding: 0 8px; + font-size: 12px; + cursor: pointer; + + &:hover { + color: var(--accent); + } + } + + > div { + position: relative; + padding: $pad; + + > button { + position: relative; + padding: 0; + width: var(--eachSize); + height: var(--eachSize); + border-radius: 4px; + + &:focus-visible { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > * { + font-size: 24px; + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 0.5px var(--divider); + + &:empty { + display: none; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/featured-photos.vue b/packages/client/src/components/featured-photos.vue new file mode 100644 index 0000000000..276344dfb4 --- /dev/null +++ b/packages/client/src/components/featured-photos.vue @@ -0,0 +1,32 @@ +<template> +<div class="xfbouadm" v-if="meta" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + }, + + data() { + return { + meta: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, +}); +</script> + +<style lang="scss" scoped> +.xfbouadm { + background-position: center; + background-size: cover; +} +</style> diff --git a/packages/client/src/components/file-type-icon.vue b/packages/client/src/components/file-type-icon.vue new file mode 100644 index 0000000000..be1af5e501 --- /dev/null +++ b/packages/client/src/components/file-type-icon.vue @@ -0,0 +1,28 @@ +<template> +<span class="mk-file-type-icon"> + <template v-if="kind == 'image'"><i class="fas fa-file-image"></i></template> +</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + type: { + type: String, + required: true, + } + }, + data() { + return { + }; + }, + computed: { + kind(): string { + return this.type.split('/')[0]; + } + } +}); +</script> diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/follow-button.vue new file mode 100644 index 0000000000..a96899027f --- /dev/null +++ b/packages/client/src/components/follow-button.vue @@ -0,0 +1,210 @@ +<template> +<button class="kpoogebi _button" + :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait"> + <template v-if="hasPendingFollowRequestFromYou && user.isLocked"> + <span v-if="full">{{ $ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i> + </template> + <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 --> + <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i> + </template> + <template v-else-if="isFollowing"> + <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i> + </template> + <template v-else-if="!isFollowing && user.isLocked"> + <span v-if="full">{{ $ts.followRequest }}</span><i class="fas fa-plus"></i> + </template> + <template v-else-if="!isFollowing && !user.isLocked"> + <span v-if="full">{{ $ts.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> + </template> +</button> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + full: { + type: Boolean, + required: false, + default: false, + }, + large: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isFollowing: this.user.isFollowing, + hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou, + wait: false, + connection: null, + }; + }, + + created() { + // 渡されたユーザー情報が不完全な場合 + if (this.user.isFollowing == null) { + os.api('users/show', { + userId: this.user.id + }).then(u => { + this.isFollowing = u.isFollowing; + this.hasPendingFollowRequestFromYou = u.hasPendingFollowRequestFromYou; + }); + } + }, + + mounted() { + this.connection = markRaw(os.stream.useChannel('main')); + + this.connection.on('follow', this.onFollowChange); + this.connection.on('unfollow', this.onFollowChange); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + onFollowChange(user) { + if (user.id == this.user.id) { + this.isFollowing = user.isFollowing; + this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; + } + }, + + async onClick() { + this.wait = true; + + try { + if (this.isFollowing) { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }), + showCancelButton: true + }); + + if (canceled) return; + + await os.api('following/delete', { + userId: this.user.id + }); + } else { + if (this.hasPendingFollowRequestFromYou) { + await os.api('following/requests/cancel', { + userId: this.user.id + }); + } else if (this.user.isLocked) { + await os.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } else { + await os.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } + } + } catch (e) { + console.error(e); + } finally { + this.wait = false; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.kpoogebi { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + height: 31px; + font-size: 16px; + border-radius: 32px; + background: #fff; + + &.full { + padding: 0 8px 0 12px; + font-size: 14px; + } + + &.large { + font-size: 16px; + height: 38px; + padding: 0 12px 0 16px; + } + + &:not(.full) { + width: 31px; + } + + &:focus-visible { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 32px; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } + + > span { + margin-right: 6px; + } +} +</style> diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/forgot-password.vue new file mode 100644 index 0000000000..a42ea5864a --- /dev/null +++ b/packages/client/src/components/forgot-password.vue @@ -0,0 +1,84 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :height="400" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.forgotPassword }}</template> + + <form class="bafeceda" @submit.prevent="onSubmit" v-if="$instance.enableEmail"> + <div class="main _formRoot"> + <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + </MkInput> + + <MkInput class="_formBlock" v-model="email" type="email" spellcheck="false" required> + <template #label>{{ $ts.emailAddress }}</template> + <template #caption>{{ $ts._forgotPassword.enterEmail }}</template> + </MkInput> + + <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton> + </div> + <div class="sub"> + <MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA> + </div> + </form> + <div v-else> + {{ $ts._forgotPassword.contactAdmin }} + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XModalWindow, + MkButton, + MkInput, + }, + + emits: ['done', 'closed'], + + data() { + return { + username: '', + email: '', + processing: false, + }; + }, + + methods: { + async onSubmit() { + this.processing = true; + await os.apiWithDialog('request-reset-password', { + username: this.username, + email: this.email, + }); + + this.$emit('done'); + this.$refs.dialog.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.bafeceda { + > .main { + padding: 24px; + } + + > .sub { + border-top: solid 0.5px var(--divider); + padding: 24px; + } +} +</style> diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/form-dialog.vue new file mode 100644 index 0000000000..172e6a5138 --- /dev/null +++ b/packages/client/src/components/form-dialog.vue @@ -0,0 +1,125 @@ +<template> +<XModalWindow ref="dialog" + :width="450" + :can-close="false" + :with-ok-button="true" + :ok-button-disabled="false" + @click="cancel()" + @ok="ok()" + @close="cancel()" + @closed="$emit('closed')" +> + <template #header> + {{ title }} + </template> + <FormBase class="xkpnjxcv"> + <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> + <FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> + <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormInput> + <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text"> + <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormInput> + <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]"> + <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormTextarea> + <FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> + <span v-text="form[item].label || item"></span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormSwitch> + <FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option> + </FormSelect> + <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> + <template #desc><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <option v-for="item in form[item].options" :value="item.value" :key="item.value">{{ item.label }}</option> + </FormRadios> + <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step"> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormRange> + <FormButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> + <span v-text="form[item].content || item"></span> + </FormButton> + </template> + </FormBase> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import FormBase from './debobigego/base.vue'; +import FormInput from './debobigego/input.vue'; +import FormTextarea from './debobigego/textarea.vue'; +import FormSwitch from './debobigego/switch.vue'; +import FormSelect from './debobigego/select.vue'; +import FormRange from './debobigego/range.vue'; +import FormButton from './debobigego/button.vue'; +import FormRadios from './debobigego/radios.vue'; + +export default defineComponent({ + components: { + XModalWindow, + FormBase, + FormInput, + FormTextarea, + FormSwitch, + FormSelect, + FormRange, + FormButton, + FormRadios, + }, + + props: { + title: { + type: String, + required: true, + }, + form: { + type: Object, + required: true, + }, + }, + + emits: ['done'], + + data() { + return { + values: {} + }; + }, + + created() { + for (const item in this.form) { + this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null; + } + }, + + methods: { + ok() { + this.$emit('done', { + result: this.values + }); + this.$refs.dialog.close(); + }, + + cancel() { + this.$emit('done', { + canceled: true + }); + this.$refs.dialog.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.xkpnjxcv { + +} +</style> diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue new file mode 100644 index 0000000000..f2c1ead00c --- /dev/null +++ b/packages/client/src/components/form/input.vue @@ -0,0 +1,315 @@ +<template> +<div class="matxzzsk"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ inline, disabled, focused }"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import { debounce } from 'throttle-debounce'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + modelValue: { + required: true + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + step: { + required: false + }, + datalist: { + type: Array, + required: false, + }, + inline: { + type: Boolean, + required: false, + default: false + }, + debounce: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + + setup(props, context) { + const { modelValue, type, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const id = Math.random().toString(); // TODO: uuid? + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + const updated = () => { + changed.value = false; + if (type?.value === 'number') { + context.emit('update:modelValue', parseFloat(v.value)); + } else { + context.emit('update:modelValue', v.value); + } + }; + + const debouncedUpdated = debounce(1000, updated); + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + return { + id, + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + focus, + onInput, + onKeydown, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.matxzzsk { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + $height: 42px; + position: relative; + + > input { + appearance: none; + -webkit-appearance: none; + display: block; + height: $height; + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + + &:hover { + border-color: var(--inputBorderHover); + } + } + + > .prefix, + > .suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: $height; + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 6px; + } + + > .suffix { + right: 0; + padding-left: 6px; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > input { + border-color: var(--accent); + //box-shadow: 0 0 0 4px var(--focus); + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + } +} +</style> diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue new file mode 100644 index 0000000000..0f31d8fa0a --- /dev/null +++ b/packages/client/src/components/form/radio.vue @@ -0,0 +1,122 @@ +<template> +<div + class="novjtctn" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click="toggle" +> + <input type="radio" + :disabled="disabled" + > + <span class="button"> + <span></span> + </span> + <span class="label"><slot></slot></span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + modelValue: { + required: false + }, + value: { + required: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.modelValue === this.value; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:modelValue', this.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.novjtctn { + position: relative; + display: inline-block; + margin: 8px 20px 0 0; + text-align: left; + cursor: pointer; + transition: all 0.3s; + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + + &, * { + cursor: not-allowed !important; + } + } + + &.checked { + > .button { + border-color: var(--accent); + + &:after { + background-color: var(--accent); + transform: scale(1); + opacity: 1; + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: absolute; + width: 20px; + height: 20px; + background: none; + border: solid 2px var(--inputBorder); + border-radius: 100%; + transition: inherit; + + &:after { + content: ''; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } + } + + > .label { + margin-left: 28px; + display: block; + font-size: 16px; + line-height: 20px; + cursor: pointer; + } +} +</style> diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue new file mode 100644 index 0000000000..998a738202 --- /dev/null +++ b/packages/client/src/components/form/radios.vue @@ -0,0 +1,54 @@ +<script lang="ts"> +import { defineComponent, h } from 'vue'; +import MkRadio from './radio.vue'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + modelValue: { + required: false + }, + }, + data() { + return { + value: this.modelValue, + } + }, + watch: { + value() { + this.$emit('update:modelValue', this.value); + } + }, + render() { + let options = this.$slots.default(); + + // なぜかFragmentになることがあるため + if (options.length === 1 && options[0].props == null) options = options[0].children; + + return h('div', { + class: 'novjtcto' + }, [ + ...options.map(option => h(MkRadio, { + key: option.key, + value: option.props.value, + modelValue: this.value, + 'onUpdate:modelValue': value => this.value = value, + }, option.children)) + ]); + } +}); +</script> + +<style lang="scss"> +.novjtcto { + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } +} +</style> diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue new file mode 100644 index 0000000000..4cfe66a8fc --- /dev/null +++ b/packages/client/src/components/form/range.vue @@ -0,0 +1,139 @@ +<template> +<div class="timctyfi" :class="{ focused, disabled }"> + <div class="icon"><slot name="icon"></slot></div> + <span class="label"><slot name="label"></slot></span> + <input + type="range" + ref="input" + v-model="v" + :disabled="disabled" + :min="min" + :max="max" + :step="step" + :autofocus="autofocus" + @focus="focused = true" + @blur="focused = false" + @input="$emit('update:value', $event.target.value)" + /> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: false, + default: 0 + }, + disabled: { + type: Boolean, + required: false, + default: false + }, + min: { + type: Number, + required: false, + default: 0 + }, + max: { + type: Number, + required: false, + default: 100 + }, + step: { + type: Number, + required: false, + default: 1 + }, + autofocus: { + type: Boolean, + required: false + } + }, + data() { + return { + v: this.value, + focused: false + }; + }, + watch: { + value(v) { + this.v = parseFloat(v); + } + }, + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$refs.input.focus(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.timctyfi { + position: relative; + margin: 8px; + + > .icon { + display: inline-block; + width: 24px; + text-align: center; + } + + > .title { + pointer-events: none; + font-size: 16px; + color: var(--inputLabel); + overflow: hidden; + } + + > input { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--X10); + height: 7px; + margin: 0 8px; + outline: 0; + border: 0; + border-radius: 7px; + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + box-sizing: content-box; + } + + &::-moz-range-thumb { + -moz-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + } + } +} +</style> diff --git a/packages/client/src/components/form/section.vue b/packages/client/src/components/form/section.vue new file mode 100644 index 0000000000..8eac40a0db --- /dev/null +++ b/packages/client/src/components/form/section.vue @@ -0,0 +1,31 @@ +<template> +<div class="vrtktovh" v-size="{ max: [500] }" v-sticky-container> + <div class="label"><slot name="label"></slot></div> + <div class="main"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.vrtktovh { + border-top: solid 0.5px var(--divider); + + > .label { + font-weight: bold; + padding: 24px 0 16px 0; + } + + > .main { + margin-bottom: 32px; + } +} +</style> diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue new file mode 100644 index 0000000000..f7eb5cd14d --- /dev/null +++ b/packages/client/src/components/form/select.vue @@ -0,0 +1,312 @@ +<template> +<div class="vblkjoeq"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick" ref="container"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <select class="select" ref="inputEl" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + @focus="focused = true" + @blur="focused = false" + @input="onInput" + > + <slot></slot> + </select> + <div class="suffix" ref="suffixEl"><i class="fas fa-chevron-down"></i></div> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + modelValue: { + required: true + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['change', 'update:modelValue'], + + setup(props, context) { + const { modelValue, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + const container = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + const onClick = (ev: MouseEvent) => { + focused.value = true; + + const menu = []; + let options = context.slots.default(); + + const pushOption = (option: VNode) => { + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + }; + + const scanOptions = (options: VNode[]) => { + for (const vnode of options) { + if (vnode.type === 'optgroup') { + const optgroup = vnode; + menu.push({ + type: 'label', + text: optgroup.props.label, + }); + scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + scanOptions(fragment.children); + } else { + const option = vnode; + pushOption(option); + } + } + }; + + scanOptions(options); + + os.popupMenu(menu, container.value, { + width: container.value.offsetWidth, + }).then(() => { + focused.value = false; + }); + }; + + return { + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + container, + focus, + onInput, + onClick, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.vblkjoeq { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + $height: 42px; + position: relative; + cursor: pointer; + + &:hover { + > .select { + border-color: var(--inputBorderHover); + } + } + + > .select { + appearance: none; + -webkit-appearance: none; + display: block; + height: $height; + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--inputBorder); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + cursor: pointer; + transition: border-color 0.1s ease-out; + pointer-events: none; + } + + > .prefix, + > .suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: $height; + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 6px; + } + + > .suffix { + right: 0; + padding-left: 6px; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > select { + border-color: var(--accent); + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + } +} +</style> diff --git a/packages/client/src/components/form/slot.vue b/packages/client/src/components/form/slot.vue new file mode 100644 index 0000000000..8580c1307d --- /dev/null +++ b/packages/client/src/components/form/slot.vue @@ -0,0 +1,50 @@ +<template> +<div class="adhpbeou"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="content"> + <slot></slot> + </div> + <div class="caption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.adhpbeou { + margin: 1.5em 0; + + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .content { + position: relative; + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; + } +} +</style> diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue new file mode 100644 index 0000000000..85f8b7c870 --- /dev/null +++ b/packages/client/src/components/form/switch.vue @@ -0,0 +1,150 @@ +<template> +<div + class="ziffeoms" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click.prevent="toggle" +> + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> + <span class="handle"></span> + </span> + <span class="label"> + <span><slot></slot></span> + <p><slot name="caption"></slot></p> + </span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + modelValue: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.modelValue; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:modelValue', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ziffeoms { + position: relative; + display: flex; + cursor: pointer; + transition: all 0.3s; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 0; + width: 36px; + height: 26px; + background: var(--switchBg); + outline: none; + border-radius: 999px; + transition: inherit; + + > .handle { + position: absolute; + top: 0; + bottom: 0; + left: 5px; + margin: auto 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 16px; + height: 16px; + background-color: #fff; + } + } + + > .label { + margin-left: 16px; + margin-top: 2px; + display: block; + cursor: pointer; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + + > p { + margin: 0; + color: var(--fgTransparentWeak); + font-size: 90%; + } + } + + &:hover { + > .button { + background-color: var(--accentedBg); + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent); + border-color: var(--accent); + + > .handle { + transform: translateX(10px); + } + } + } +} +</style> diff --git a/packages/client/src/components/form/textarea.vue b/packages/client/src/components/form/textarea.vue new file mode 100644 index 0000000000..fdb24f1e2b --- /dev/null +++ b/packages/client/src/components/form/textarea.vue @@ -0,0 +1,252 @@ +<template> +<div class="adhpbeos"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ disabled, focused, tall, pre }"> + <textarea ref="inputEl" + :class="{ code, _monospace: code }" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + ></textarea> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import { debounce } from 'throttle-debounce'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + modelValue: { + required: true + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + code: { + type: Boolean, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + debounce: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + + setup(props, context) { + const { modelValue, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + const debouncedUpdated = debounce(1000, updated); + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); + }); + + return { + v, + focused, + invalid, + changed, + filled, + inputEl, + focus, + onInput, + onKeydown, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.adhpbeos { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + position: relative; + + > textarea { + appearance: none; + -webkit-appearance: none; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + + &:hover { + border-color: var(--inputBorderHover); + } + } + + &.focused { + > textarea { + border-color: var(--accent); + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + &.tall { + > textarea { + min-height: 200px; + } + } + + &.pre { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/packages/client/src/components/formula-core.vue b/packages/client/src/components/formula-core.vue new file mode 100644 index 0000000000..cf8dee872b --- /dev/null +++ b/packages/client/src/components/formula-core.vue @@ -0,0 +1,34 @@ + +<template> +<div v-if="block" v-html="compiledFormula"></div> +<span v-else v-html="compiledFormula"></span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as katex from 'katex';import * as os from '@/os'; + +export default defineComponent({ + props: { + formula: { + type: String, + required: true + }, + block: { + type: Boolean, + required: true + } + }, + computed: { + compiledFormula(): any { + return katex.renderToString(this.formula, { + throwOnError: false + } as any); + } + } +}); +</script> + +<style> +@import "../../node_modules/katex/dist/katex.min.css"; +</style> diff --git a/packages/client/src/components/formula.vue b/packages/client/src/components/formula.vue new file mode 100644 index 0000000000..fbb40bace7 --- /dev/null +++ b/packages/client/src/components/formula.vue @@ -0,0 +1,23 @@ +<template> +<XFormula :formula="formula" :block="block" /> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({ + components: { + XFormula: defineAsyncComponent(() => import('./formula-core.vue')) + }, + props: { + formula: { + type: String, + required: true + }, + block: { + type: Boolean, + required: true + } + } +}); +</script> diff --git a/packages/client/src/components/gallery-post-preview.vue b/packages/client/src/components/gallery-post-preview.vue new file mode 100644 index 0000000000..8245902976 --- /dev/null +++ b/packages/client/src/components/gallery-post-preview.vue @@ -0,0 +1,126 @@ +<template> +<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1"> + <div class="thumbnail"> + <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> + </div> + <article> + <header> + <MkAvatar :user="post.user" class="avatar"/> + </header> + <footer> + <span class="title">{{ post.title }}</span> + </footer> + </article> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { userName } from '@/filters/user'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + ImgWithBlurhash + }, + props: { + post: { + type: Object, + required: true + }, + }, + methods: { + userName + } +}); +</script> + +<style lang="scss" scoped> +.ttasepnz { + display: block; + position: relative; + height: 200px; + + &:hover { + text-decoration: none; + color: var(--accent); + + > .thumbnail { + transform: scale(1.1); + } + + > article { + > footer { + &:before { + opacity: 1; + } + } + } + } + + > .thumbnail { + width: 100%; + height: 100%; + position: absolute; + transition: all 0.5s ease; + + > .img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + > article { + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + + > header { + position: absolute; + top: 0; + width: 100%; + padding: 12px; + box-sizing: border-box; + display: flex; + + > .avatar { + margin-left: auto; + width: 32px; + height: 32px; + } + } + + > footer { + position: absolute; + bottom: 0; + width: 100%; + padding: 16px; + box-sizing: border-box; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + + &:before { + content: ""; + display: block; + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(rgba(0, 0, 0, 0.4), transparent); + opacity: 0; + transition: opacity 0.5s ease; + } + + > .title { + font-weight: bold; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue new file mode 100644 index 0000000000..5db61203c6 --- /dev/null +++ b/packages/client/src/components/global/a.vue @@ -0,0 +1,138 @@ +<template> +<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu"> + <slot></slot> +</a> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { router } from '@/router'; +import { url } from '@/config'; +import { popout } from '@/scripts/popout'; +import { ColdDeviceStorage } from '@/store'; + +export default defineComponent({ + inject: { + navHook: { + default: null + }, + sideViewHook: { + default: null + } + }, + + props: { + to: { + type: String, + required: true, + }, + activeClass: { + type: String, + required: false, + }, + behavior: { + type: String, + required: false, + }, + }, + + computed: { + active() { + if (this.activeClass == null) return false; + const resolved = router.resolve(this.to); + if (resolved.path == this.$route.path) return true; + if (resolved.name == null) return false; + if (this.$route.name == null) return false; + return resolved.name == this.$route.name; + } + }, + + methods: { + onContextmenu(e) { + if (window.getSelection().toString() !== '') return; + os.contextMenu([{ + type: 'label', + text: this.to, + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.to); + } + }, this.sideViewHook ? { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.sideViewHook(this.to); + } + } : undefined, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: () => { + this.$router.push(this.to); + } + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.to, '_blank'); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(`${url}${this.to}`); + } + }], e); + }, + + window() { + os.pageWindow(this.to); + }, + + modalWindow() { + os.modalPageWindow(this.to); + }, + + popout() { + popout(this.to); + }, + + nav() { + if (this.behavior === 'browser') { + location.href = this.to; + return; + } + + if (this.to.startsWith('/my/messaging')) { + if (ColdDeviceStorage.get('chatOpenBehavior') === 'window') return this.window(); + if (ColdDeviceStorage.get('chatOpenBehavior') === 'popout') return this.popout(); + } + + if (this.behavior) { + if (this.behavior === 'window') { + return this.window(); + } else if (this.behavior === 'modalWindow') { + return this.modalWindow(); + } + } + + if (this.navHook) { + this.navHook(this.to); + } else { + if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') { + return this.sideViewHook(this.to); + } + + if (this.$router.currentRoute.value.path === this.to) { + window.scroll({ top: 0, behavior: 'smooth' }); + } else { + this.$router.push(this.to); + } + } + } + } +}); +</script> diff --git a/packages/client/src/components/global/acct.vue b/packages/client/src/components/global/acct.vue new file mode 100644 index 0000000000..b0c41c99c0 --- /dev/null +++ b/packages/client/src/components/global/acct.vue @@ -0,0 +1,38 @@ +<template> +<span class="mk-acct"> + <span class="name">@{{ user.username }}</span> + <span class="host" v-if="user.host || detail || $store.state.showFullAcct">@{{ user.host || host }}</span> +</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import { host } from '@/config'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + detail: { + type: Boolean, + default: false + }, + }, + data() { + return { + host: toUnicode(host), + }; + } +}); +</script> + +<style lang="scss" scoped> +.mk-acct { + > .host { + opacity: 0.5; + } +} +</style> diff --git a/packages/client/src/components/global/ad.vue b/packages/client/src/components/global/ad.vue new file mode 100644 index 0000000000..71cb16740c --- /dev/null +++ b/packages/client/src/components/global/ad.vue @@ -0,0 +1,200 @@ +<template> +<div class="qiivuoyo" v-if="ad"> + <div class="main" :class="ad.place" v-if="!showMenu"> + <a :href="ad.url" target="_blank"> + <img :src="ad.imageUrl"> + <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button> + </a> + </div> + <div class="menu" v-else> + <div class="body"> + <div>Ads by {{ host }}</div> + <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>--> + <MkButton v-if="ad.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton> + <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button> + </div> + </div> +</div> +<div v-else></div> +</template> + +<script lang="ts"> +import { defineComponent, ref } from 'vue'; +import { Instance, instance } from '@/instance'; +import { host } from '@/config'; +import MkButton from '@/components/ui/button.vue'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + prefer: { + type: Array, + required: true + }, + specify: { + type: Object, + required: false + }, + }, + + setup(props) { + const showMenu = ref(false); + const toggleMenu = () => { + showMenu.value = !showMenu.value; + }; + + const choseAd = (): Instance['ads'][number] | null => { + if (props.specify) { + return props.specify as Instance['ads'][number]; + } + + const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { + ...ad, + ratio: 0 + } : ad); + + let ads = allAds.filter(ad => props.prefer.includes(ad.place)); + + if (ads.length === 0) { + ads = allAds.filter(ad => ad.place === 'square'); + } + + const lowPriorityAds = ads.filter(ad => ad.ratio === 0); + ads = ads.filter(ad => ad.ratio !== 0); + + if (ads.length === 0) { + if (lowPriorityAds.length !== 0) { + return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)]; + } else { + return null; + } + } + + const totalFactor = ads.reduce((a, b) => a + b.ratio, 0); + const r = Math.random() * totalFactor; + + let stackedFactor = 0; + for (const ad of ads) { + if (r >= stackedFactor && r <= stackedFactor + ad.ratio) { + return ad; + } else { + stackedFactor += ad.ratio; + } + } + + return null; + }; + + const chosen = ref(choseAd()); + + const reduceFrequency = () => { + if (chosen.value == null) return; + if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; + defaultStore.push('mutedAds', chosen.value.id); + os.success(); + chosen.value = choseAd(); + showMenu.value = false; + }; + + return { + ad: chosen, + showMenu, + toggleMenu, + host, + reduceFrequency, + }; + } +}); +</script> + +<style lang="scss" scoped> +.qiivuoyo { + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px ); + + > .main { + text-align: center; + + > a { + display: inline-block; + position: relative; + vertical-align: bottom; + + &:hover { + > img { + filter: contrast(120%); + } + } + + > img { + display: block; + object-fit: contain; + margin: auto; + } + + > .menu { + position: absolute; + top: 0; + right: 0; + background: var(--panel); + } + } + + &.square { + > a , + > a > img { + max-width: min(300px, 100%); + max-height: 300px; + } + } + + &.horizontal { + padding: 8px; + + > a , + > a > img { + max-width: min(600px, 100%); + max-height: 80px; + } + } + + &.horizontal-big { + padding: 8px; + + > a , + > a > img { + max-width: min(600px, 100%); + max-height: 250px; + } + } + + &.vertical { + > a , + > a > img { + max-width: min(100px, 100%); + } + } + } + + > .menu { + padding: 8px; + text-align: center; + + > .body { + padding: 8px; + margin: 0 auto; + max-width: 400px; + border: solid 1px var(--divider); + + > .button { + margin: 8px auto; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/avatar.vue new file mode 100644 index 0000000000..e509e893da --- /dev/null +++ b/packages/client/src/components/global/avatar.vue @@ -0,0 +1,163 @@ +<template> +<span class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> + <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> +</span> +<MkA class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> + <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; +import { acct, userPage } from '@/filters/user'; +import MkUserOnlineIndicator from '@/components/user-online-indicator.vue'; + +export default defineComponent({ + components: { + MkUserOnlineIndicator + }, + props: { + user: { + type: Object, + required: true + }, + target: { + required: false, + default: null + }, + disableLink: { + required: false, + default: false + }, + disablePreview: { + required: false, + default: false + }, + showIndicator: { + required: false, + default: false + } + }, + emits: ['click'], + computed: { + cat(): boolean { + return this.user.isCat; + }, + url(): string { + return this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(this.user.avatarUrl) + : this.user.avatarUrl; + }, + }, + watch: { + 'user.avatarBlurhash'() { + if (this.$el == null) return; + this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); + } + }, + mounted() { + this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); + }, + methods: { + onClick(e) { + this.$emit('click', e); + }, + acct, + userPage + } +}); +</script> + +<style lang="scss" scoped> +@keyframes earwiggleleft { + from { transform: rotate(37.6deg) skew(30deg); } + 25% { transform: rotate(10deg) skew(30deg); } + 50% { transform: rotate(20deg) skew(30deg); } + 75% { transform: rotate(0deg) skew(30deg); } + to { transform: rotate(37.6deg) skew(30deg); } +} + +@keyframes earwiggleright { + from { transform: rotate(-37.6deg) skew(-30deg); } + 30% { transform: rotate(-10deg) skew(-30deg); } + 55% { transform: rotate(-20deg) skew(-30deg); } + 75% { transform: rotate(0deg) skew(-30deg); } + to { transform: rotate(-37.6deg) skew(-30deg); } +} + +.eiwwqkts { + position: relative; + display: inline-block; + vertical-align: bottom; + flex-shrink: 0; + border-radius: 100%; + line-height: 16px; + + > .inner { + position: absolute; + bottom: 0; + left: 0; + right: 0; + top: 0; + border-radius: 100%; + z-index: 1; + overflow: hidden; + object-fit: cover; + width: 100%; + height: 100%; + } + + > .indicator { + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + width: 20%; + height: 20%; + } + + &.square { + border-radius: 20%; + + > .inner { + border-radius: 20%; + } + } + + &.cat { + &:before, &:after { + background: #df548f; + border: solid 4px currentColor; + box-sizing: border-box; + content: ''; + display: inline-block; + height: 50%; + width: 50%; + } + + &:before { + border-radius: 0 75% 75%; + transform: rotate(37.5deg) skew(30deg); + } + + &:after { + border-radius: 75% 0 75% 75%; + transform: rotate(-37.5deg) skew(-30deg); + } + + &:hover { + &:before { + animation: earwiggleleft 1s infinite; + } + + &:after { + animation: earwiggleright 1s infinite; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/ellipsis.vue b/packages/client/src/components/global/ellipsis.vue new file mode 100644 index 0000000000..0a46f486d6 --- /dev/null +++ b/packages/client/src/components/global/ellipsis.vue @@ -0,0 +1,34 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="scss" scoped> +.mk-ellipsis { + > span { + animation: ellipsis 1.4s infinite ease-in-out both; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.16s; + } + + &:nth-child(3) { + animation-delay: 0.32s; + } + } +} + +@keyframes ellipsis { + 0%, 80%, 100% { + opacity: 1; + } + 40% { + opacity: 0; + } +} +</style> diff --git a/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/emoji.vue new file mode 100644 index 0000000000..67a3dea2c5 --- /dev/null +++ b/packages/client/src/components/global/emoji.vue @@ -0,0 +1,125 @@ +<template> +<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/> +<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/> +<span v-else-if="char && useOsNativeEmojis">{{ char }}</span> +<span v-else>{{ emoji }}</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { twemojiSvgBase } from '@/scripts/twemoji-base'; + +export default defineComponent({ + props: { + emoji: { + type: String, + required: true + }, + normal: { + type: Boolean, + required: false, + default: false + }, + noStyle: { + type: Boolean, + required: false, + default: false + }, + customEmojis: { + required: false + }, + isReaction: { + type: Boolean, + default: false + }, + }, + + data() { + return { + url: null, + char: null, + customEmoji: null + } + }, + + computed: { + isCustom(): boolean { + return this.emoji.startsWith(':'); + }, + + alt(): string { + return this.customEmoji ? `:${this.customEmoji.name}:` : this.char; + }, + + useOsNativeEmojis(): boolean { + return this.$store.state.useOsNativeEmojis && !this.isReaction; + }, + + ce() { + return this.customEmojis || this.$instance?.emojis || []; + } + }, + + watch: { + ce: { + handler() { + if (this.isCustom) { + const customEmoji = this.ce.find(x => x.name === this.emoji.substr(1, this.emoji.length - 2)); + if (customEmoji) { + this.customEmoji = customEmoji; + this.url = this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(customEmoji.url) + : customEmoji.url; + } + } + }, + immediate: true + }, + }, + + created() { + if (!this.isCustom) { + this.char = this.emoji; + } + + if (this.char) { + let codes = Array.from(this.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); + + this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`; + } + }, +}); +</script> + +<style lang="scss" scoped> +.mk-emoji { + height: 1.25em; + vertical-align: -0.25em; + + &.custom { + height: 2.5em; + vertical-align: middle; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } + + &.normal { + height: 1.25em; + vertical-align: -0.25em; + + &:hover { + transform: none; + } + } + } + + &.noStyle { + height: auto !important; + } +} +</style> diff --git a/packages/client/src/components/global/error.vue b/packages/client/src/components/global/error.vue new file mode 100644 index 0000000000..8ce5d16ac6 --- /dev/null +++ b/packages/client/src/components/global/error.vue @@ -0,0 +1,46 @@ +<template> +<transition :name="$store.state.animation ? 'zoom' : ''" appear> + <div class="mjndxjcg"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</p> + <MkButton @click="() => $emit('retry')" class="button">{{ $ts.retry }}</MkButton> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + data() { + return { + }; + }, +}); +</script> + +<style lang="scss" scoped> +.mjndxjcg { + padding: 32px; + text-align: center; + + > p { + margin: 0 0 8px 0; + } + + > .button { + margin: 0 auto; + } + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} +</style> diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue new file mode 100644 index 0000000000..7d5e426f2b --- /dev/null +++ b/packages/client/src/components/global/header.vue @@ -0,0 +1,360 @@ +<template> +<div class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick" ref="el"> + <template v-if="info"> + <div class="titleContainer" @click="showTabsPopup" v-if="!hideTitle"> + <MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> + <i v-else-if="info.icon" class="icon" :class="info.icon"></i> + + <div class="title"> + <MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/> + <div v-else-if="info.title" class="title">{{ info.title }}</div> + <div class="subtitle" v-if="!narrow && info.subtitle"> + {{ info.subtitle }} + </div> + <div class="subtitle activeTab" v-if="narrow && hasTabs"> + {{ info.tabs.find(tab => tab.active)?.title }} + <i class="chevron fas fa-chevron-down"></i> + </div> + </div> + </div> + <div class="tabs" v-if="!narrow || hideTitle"> + <button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + </div> + </template> + <div class="buttons right"> + <template v-if="info && info.actions && !narrow"> + <template v-for="action in info.actions"> + <MkButton class="fullButton" v-if="action.asFullButton" @click.stop="action.handler" primary><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button> + </template> + </template> + <button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { url } from '@/config'; +import { scrollToTop } from '@/scripts/scroll'; +import MkButton from '@/components/ui/button.vue'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + info: { + type: Object as PropType<{ + actions?: {}[]; + tabs?: {}[]; + }>, + required: true + }, + menu: { + required: false + }, + thin: { + required: false, + default: false + }, + }, + + setup(props) { + const el = ref<HTMLElement>(null); + const bg = ref(null); + const narrow = ref(false); + const height = ref(0); + const hasTabs = computed(() => { + return props.info.tabs && props.info.tabs.length > 0; + }); + const shouldShowMenu = computed(() => { + if (props.info == null) return false; + if (props.info.actions != null && narrow.value) return true; + if (props.info.menu != null) return true; + if (props.info.share != null) return true; + if (props.menu != null) return true; + return false; + }); + + const share = () => { + navigator.share({ + url: url + props.info.path, + ...props.info.share, + }); + }; + + const showMenu = (ev: MouseEvent) => { + let menu = props.info.menu ? props.info.menu() : []; + if (narrow.value && props.info.actions) { + menu = [...props.info.actions.map(x => ({ + text: x.text, + icon: x.icon, + action: x.handler + })), menu.length > 0 ? null : undefined, ...menu]; + } + if (props.info.share) { + if (menu.length > 0) menu.push(null); + menu.push({ + text: i18n.locale.share, + icon: 'fas fa-share-alt', + action: share + }); + } + if (props.menu) { + if (menu.length > 0) menu.push(null); + menu = menu.concat(props.menu); + } + popupMenu(menu, ev.currentTarget || ev.target); + }; + + const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs.value) return; + if (!narrow.value) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.info.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + action: tab.onClick, + })); + popupMenu(menu, ev.currentTarget || ev.target); + }; + + const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); + }; + + const onClick = () => { + scrollToTop(el.value, { behavior: 'smooth' }); + }; + + const calcBg = () => { + const rawBg = props.info?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); + }; + + onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); + }); + + if (el.value.parentElement) { + narrow.value = el.value.parentElement.offsetWidth < 500; + const ro = new ResizeObserver((entries, observer) => { + if (el.value) { + narrow.value = el.value.parentElement.offsetWidth < 500; + } + }); + ro.observe(el.value.parentElement); + onUnmounted(() => { + ro.disconnect(); + }); + } + }); + + return { + el, + bg, + narrow, + height, + hasTabs, + shouldShowMenu, + share, + showMenu, + showTabsPopup, + preventDrag, + onClick, + hideTitle: inject('shouldOmitHeaderTitle', false), + thin_: props.thin || inject('shouldHeaderThin', false) + }; + }, +}); +</script> + +<style lang="scss" scoped> +.fdidabkb { + --height: 60px; + display: flex; + position: sticky; + top: var(--stickyTop, 0); + z-index: 1000; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-bottom: solid 0.5px var(--divider); + + &.thin { + --height: 50px; + + > .buttons { + > .button { + font-size: 0.9em; + } + } + } + + &.slim { + text-align: center; + + > .titleContainer { + flex: 1; + margin: 0 auto; + margin-left: var(--height); + + > *:first-child { + margin-left: auto; + } + + > *:last-child { + margin-right: auto; + } + } + } + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + + &:after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + margin: 0 auto; + width: 100%; + height: 3px; + background: var(--accent); + } + } + + > .icon + .title { + margin-left: 8px; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/i18n.ts b/packages/client/src/components/global/i18n.ts new file mode 100644 index 0000000000..abf0c96856 --- /dev/null +++ b/packages/client/src/components/global/i18n.ts @@ -0,0 +1,42 @@ +import { h, defineComponent } from 'vue'; + +export default defineComponent({ + props: { + src: { + type: String, + required: true, + }, + tag: { + type: String, + required: false, + default: 'span', + }, + textTag: { + type: String, + required: false, + default: null, + }, + }, + render() { + let str = this.src; + const parsed = [] as (string | { arg: string; })[]; + while (true) { + const nextBracketOpen = str.indexOf('{'); + const nextBracketClose = str.indexOf('}'); + + if (nextBracketOpen === -1) { + parsed.push(str); + break; + } else { + if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); + parsed.push({ + arg: str.substring(nextBracketOpen + 1, nextBracketClose) + }); + } + + str = str.substr(nextBracketClose + 1); + } + + return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]())); + } +}); diff --git a/packages/client/src/components/global/loading.vue b/packages/client/src/components/global/loading.vue new file mode 100644 index 0000000000..7bde53c12e --- /dev/null +++ b/packages/client/src/components/global/loading.vue @@ -0,0 +1,92 @@ +<template> +<div class="yxspomdl" :class="{ inline, colored, mini }"> + <div class="ring"></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + inline: { + type: Boolean, + required: false, + default: false + }, + colored: { + type: Boolean, + required: false, + default: true + }, + mini: { + type: Boolean, + required: false, + default: false + }, + } +}); +</script> + +<style lang="scss" scoped> +@keyframes ring { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.yxspomdl { + padding: 32px; + text-align: center; + cursor: wait; + + --size: 48px; + + &.colored { + color: var(--accent); + } + + &.inline { + display: inline; + padding: 0; + --size: 32px; + } + + &.mini { + padding: 16px; + --size: 32px; + } + + > .ring { + position: relative; + display: inline-block; + vertical-align: middle; + + &:before, + &:after { + content: " "; + display: block; + box-sizing: border-box; + width: var(--size); + height: var(--size); + border-radius: 50%; + border: solid 4px; + } + + &:before { + border-color: currentColor; + opacity: 0.3; + } + + &:after { + position: absolute; + top: 0; + border-color: currentColor transparent transparent transparent; + animation: ring 0.5s linear infinite; + } + } +} +</style> diff --git a/packages/client/src/components/global/misskey-flavored-markdown.vue b/packages/client/src/components/global/misskey-flavored-markdown.vue new file mode 100644 index 0000000000..ab20404909 --- /dev/null +++ b/packages/client/src/components/global/misskey-flavored-markdown.vue @@ -0,0 +1,157 @@ +<template> +<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MfmCore from '@/components/mfm'; + +export default defineComponent({ + components: { + MfmCore + } +}); +</script> + +<style lang="scss"> +._mfm_blur_ { + filter: blur(6px); + transition: filter 0.3s; + + &:hover { + filter: blur(0px); + } +} + +@keyframes mfm-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes mfm-spinX { + 0% { transform: perspective(128px) rotateX(0deg); } + 100% { transform: perspective(128px) rotateX(360deg); } +} + +@keyframes mfm-spinY { + 0% { transform: perspective(128px) rotateY(0deg); } + 100% { transform: perspective(128px) rotateY(360deg); } +} + +@keyframes mfm-jump { + 0% { transform: translateY(0); } + 25% { transform: translateY(-16px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} + +@keyframes mfm-bounce { + 0% { transform: translateY(0) scale(1, 1); } + 25% { transform: translateY(-16px) scale(1, 1); } + 50% { transform: translateY(0) scale(1, 1); } + 75% { transform: translateY(0) scale(1.5, 0.75); } + 100% { transform: translateY(0) scale(1, 1); } +} + +// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-twitch { + 0% { transform: translate(7px, -2px) } + 5% { transform: translate(-3px, 1px) } + 10% { transform: translate(-7px, -1px) } + 15% { transform: translate(0px, -1px) } + 20% { transform: translate(-8px, 6px) } + 25% { transform: translate(-4px, -3px) } + 30% { transform: translate(-4px, -6px) } + 35% { transform: translate(-8px, -8px) } + 40% { transform: translate(4px, 6px) } + 45% { transform: translate(-3px, 1px) } + 50% { transform: translate(2px, -10px) } + 55% { transform: translate(-7px, 0px) } + 60% { transform: translate(-2px, 4px) } + 65% { transform: translate(3px, -8px) } + 70% { transform: translate(6px, 7px) } + 75% { transform: translate(-7px, -2px) } + 80% { transform: translate(-7px, -8px) } + 85% { transform: translate(9px, 3px) } + 90% { transform: translate(-3px, -2px) } + 95% { transform: translate(-10px, 2px) } + 100% { transform: translate(-2px, -6px) } +} + +// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-shake { + 0% { transform: translate(-3px, -1px) rotate(-8deg) } + 5% { transform: translate(0px, -1px) rotate(-10deg) } + 10% { transform: translate(1px, -3px) rotate(0deg) } + 15% { transform: translate(1px, 1px) rotate(11deg) } + 20% { transform: translate(-2px, 1px) rotate(1deg) } + 25% { transform: translate(-1px, -2px) rotate(-2deg) } + 30% { transform: translate(-1px, 2px) rotate(-3deg) } + 35% { transform: translate(2px, 1px) rotate(6deg) } + 40% { transform: translate(-2px, -3px) rotate(-9deg) } + 45% { transform: translate(0px, -1px) rotate(-12deg) } + 50% { transform: translate(1px, 2px) rotate(10deg) } + 55% { transform: translate(0px, -3px) rotate(8deg) } + 60% { transform: translate(1px, -1px) rotate(8deg) } + 65% { transform: translate(0px, -1px) rotate(-7deg) } + 70% { transform: translate(-1px, -3px) rotate(6deg) } + 75% { transform: translate(0px, -2px) rotate(4deg) } + 80% { transform: translate(-2px, -1px) rotate(3deg) } + 85% { transform: translate(1px, -3px) rotate(-10deg) } + 90% { transform: translate(1px, 0px) rotate(3deg) } + 95% { transform: translate(-2px, 0px) rotate(-3deg) } + 100% { transform: translate(2px, 1px) rotate(2deg) } +} + +@keyframes mfm-rubberBand { + from { transform: scale3d(1, 1, 1); } + 30% { transform: scale3d(1.25, 0.75, 1); } + 40% { transform: scale3d(0.75, 1.25, 1); } + 50% { transform: scale3d(1.15, 0.85, 1); } + 65% { transform: scale3d(0.95, 1.05, 1); } + 75% { transform: scale3d(1.05, 0.95, 1); } + to { transform: scale3d(1, 1, 1); } +} + +@keyframes mfm-rainbow { + 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } + 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } +} +</style> + +<style lang="scss" scoped> +.havbbuyv { + white-space: pre-wrap; + + &.nowrap { + white-space: pre; + word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html + overflow: hidden; + text-overflow: ellipsis; + } + + ::v-deep(.quote) { + display: block; + margin: 8px; + padding: 6px 0 6px 12px; + color: var(--fg); + border-left: solid 3px var(--fg); + opacity: 0.7; + } + + ::v-deep(pre) { + font-size: 0.8em; + } + + > ::v-deep(code) { + font-size: 0.8em; + word-break: break-all; + padding: 4px 6px; + } +} +</style> diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue new file mode 100644 index 0000000000..1129d54c71 --- /dev/null +++ b/packages/client/src/components/global/spacer.vue @@ -0,0 +1,76 @@ +<template> +<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }"> + <div ref="content" :class="$style.content"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; + +export default defineComponent({ + props: { + contentMax: { + type: Number, + required: false, + default: null, + } + }, + + setup(props, context) { + let ro: ResizeObserver; + const root = ref<HTMLElement>(null); + const content = ref<HTMLElement>(null); + const margin = ref(0); + const adjust = (rect: { width: number; height: number; }) => { + if (rect.width > (props.contentMax || 500)) { + margin.value = 32; + } else { + margin.value = 12; + } + }; + + onMounted(() => { + ro = new ResizeObserver((entries) => { + /* iOSが対応していない + adjust({ + width: entries[0].borderBoxSize[0].inlineSize, + height: entries[0].borderBoxSize[0].blockSize, + }); + */ + adjust({ + width: root.value.offsetWidth, + height: root.value.offsetHeight, + }); + }); + ro.observe(root.value); + + if (props.contentMax) { + content.value.style.maxWidth = `${props.contentMax}px`; + } + }); + + onUnmounted(() => { + ro.disconnect(); + }); + + return { + root, + content, + margin, + }; + }, +}); +</script> + +<style lang="scss" module> +.root { + box-sizing: border-box; + width: 100%; +} + +.content { + margin: 0 auto; +} +</style> diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue new file mode 100644 index 0000000000..859b2c1d73 --- /dev/null +++ b/packages/client/src/components/global/sticky-container.vue @@ -0,0 +1,74 @@ +<template> +<div ref="rootEl"> + <slot name="header"></slot> + <div ref="bodyEl"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; + +export default defineComponent({ + props: { + autoSticky: { + type: Boolean, + required: false, + default: false, + }, + }, + + setup(props, context) { + const rootEl = ref<HTMLElement>(null); + const bodyEl = ref<HTMLElement>(null); + + const calc = () => { + const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; + + const header = rootEl.value.children[0]; + if (header === bodyEl.value) { + bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); + } else { + bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); + + if (props.autoSticky) { + header.style.setProperty('--stickyTop', currentStickyTop); + header.style.position = 'sticky'; + header.style.top = 'var(--stickyTop)'; + header.style.zIndex = '1'; + } + } + }; + + onMounted(() => { + calc(); + + const observer = new MutationObserver(() => { + setTimeout(() => { + calc(); + }, 100); + }); + + observer.observe(rootEl.value, { + attributes: false, + childList: true, + subtree: false, + }); + + onUnmounted(() => { + observer.disconnect(); + }); + }); + + return { + rootEl, + bodyEl, + }; + }, +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue new file mode 100644 index 0000000000..6a330a2307 --- /dev/null +++ b/packages/client/src/components/global/time.vue @@ -0,0 +1,73 @@ +<template> +<time :title="absolute"> + <template v-if="mode == 'relative'">{{ relative }}</template> + <template v-else-if="mode == 'absolute'">{{ absolute }}</template> + <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template> +</time> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + time: { + type: [Date, String], + required: true + }, + mode: { + type: String, + default: 'relative' + } + }, + data() { + return { + tickId: null, + now: new Date() + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + }, + absolute(): string { + return this._time.toLocaleString(); + }, + relative(): string { + const time = this._time; + const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; + return ( + ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) : + ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) : + ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) : + ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) : + ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) : + ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? this.$ts._ago.justNow : + ago < -1 ? this.$ts._ago.future : + this.$ts._ago.unknown); + } + }, + created() { + if (this.mode == 'relative' || this.mode == 'detail') { + this.tickId = window.requestAnimationFrame(this.tick); + } + }, + unmounted() { + if (this.mode === 'relative' || this.mode === 'detail') { + window.clearTimeout(this.tickId); + } + }, + methods: { + tick() { + // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する + this.now = new Date(); + + this.tickId = setTimeout(() => { + window.requestAnimationFrame(this.tick); + }, 10000); + } + } +}); +</script> diff --git a/packages/client/src/components/global/url.vue b/packages/client/src/components/global/url.vue new file mode 100644 index 0000000000..092fe6620c --- /dev/null +++ b/packages/client/src/components/global/url.vue @@ -0,0 +1,142 @@ +<template> +<component :is="self ? 'MkA' : 'a'" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" + @mouseover="onMouseover" + @mouseleave="onMouseleave" + @contextmenu.stop="() => {}" +> + <template v-if="!self"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span class="port" v-if="port != ''">:{{ port }}</span> + </template> + <template v-if="pathname === '/' && self"> + <span class="self">{{ hostname }}</span> + </template> + <span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> +</component> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode as decodePunycode } from 'punycode/'; +import { url as local } from '@/config'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + url: { + type: String, + required: true, + }, + rel: { + type: String, + required: false, + } + }, + data() { + const self = this.url.startsWith(local); + return { + local, + schema: null as string | null, + hostname: null as string | null, + port: null as string | null, + pathname: null as string | null, + query: null as string | null, + hash: null as string | null, + self: self, + attr: self ? 'to' : 'href', + target: self ? null : '_blank', + showTimer: null, + hideTimer: null, + checkTimer: null, + close: null, + }; + }, + created() { + const url = new URL(this.url); + this.schema = url.protocol; + this.hostname = decodePunycode(url.hostname); + this.port = url.port; + this.pathname = decodeURIComponent(url.pathname); + this.query = decodeURIComponent(url.search); + this.hash = decodeURIComponent(url.hash); + }, + methods: { + async showPreview() { + if (!document.body.contains(this.$el)) return; + if (this.close) return; + + const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), { + url: this.url, + source: this.$el + }); + + this.close = () => { + dispose(); + }; + + this.checkTimer = setInterval(() => { + if (!document.body.contains(this.$el)) this.closePreview(); + }, 1000); + }, + closePreview() { + if (this.close) { + clearInterval(this.checkTimer); + this.close(); + this.close = null; + } + }, + onMouseover() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.showTimer = setTimeout(this.showPreview, 500); + }, + onMouseleave() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.hideTimer = setTimeout(this.closePreview, 500); + } + } +}); +</script> + +<style lang="scss" scoped> +.ieqqeuvs { + word-break: break-all; + + > .icon { + padding-left: 2px; + font-size: .9em; + } + + > .self { + font-weight: bold; + } + + > .schema { + opacity: 0.5; + } + + > .hostname { + font-weight: bold; + } + + > .pathname { + opacity: 0.8; + } + + > .query { + opacity: 0.5; + } + + > .hash { + font-style: italic; + } +} +</style> diff --git a/packages/client/src/components/global/user-name.vue b/packages/client/src/components/global/user-name.vue new file mode 100644 index 0000000000..bc93a8ea30 --- /dev/null +++ b/packages/client/src/components/global/user-name.vue @@ -0,0 +1,20 @@ +<template> +<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + nowrap: { + type: Boolean, + default: true + }, + } +}); +</script> diff --git a/packages/client/src/components/google.vue b/packages/client/src/components/google.vue new file mode 100644 index 0000000000..c48feffbf1 --- /dev/null +++ b/packages/client/src/components/google.vue @@ -0,0 +1,64 @@ +<template> +<div class="mk-google"> + <input type="search" v-model="query" :placeholder="q"> + <button @click="search"><i class="fas fa-search"></i> {{ $ts.search }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + q: { + type: String, + required: true, + } + }, + data() { + return { + query: null, + }; + }, + mounted() { + this.query = this.q; + }, + methods: { + search() { + window.open(`https://www.google.com/search?q=${this.query}`, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-google { + display: flex; + margin: 8px 0; + + > input { + flex-shrink: 1; + padding: 10px; + width: 100%; + height: 40px; + font-size: 16px; + border: solid 1px var(--divider); + border-radius: 4px 0 0 4px; + -webkit-appearance: textfield; + } + + > button { + flex-shrink: 0; + margin: 0; + padding: 0 16px; + border: solid 1px var(--divider); + border-left: none; + border-radius: 0 4px 4px 0; + + &:active { + box-shadow: 0 2px 4px rgba(#000, 0.15) inset; + } + } +} +</style> diff --git a/packages/client/src/components/image-viewer.vue b/packages/client/src/components/image-viewer.vue new file mode 100644 index 0000000000..fc28c30b56 --- /dev/null +++ b/packages/client/src/components/image-viewer.vue @@ -0,0 +1,85 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="xubzgfga"> + <header>{{ image.name }}</header> + <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> + <footer> + <span>{{ image.type }}</span> + <span>{{ bytes(image.size) }}</span> + <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> + </footer> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkModal from '@/components/ui/modal.vue'; + +export default defineComponent({ + components: { + MkModal, + }, + + props: { + image: { + type: Object, + required: true + }, + }, + + emits: ['closed'], + + methods: { + bytes, + number, + } +}); +</script> + +<style lang="scss" scoped> +.xubzgfga { + display: flex; + flex-direction: column; + height: 100%; + + > header, + > footer { + align-self: center; + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + flex: 1; + min-height: 0; + object-fit: contain; + width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } +} +</style> diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/img-with-blurhash.vue new file mode 100644 index 0000000000..7e80b00208 --- /dev/null +++ b/packages/client/src/components/img-with-blurhash.vue @@ -0,0 +1,100 @@ +<template> +<div class="xubzgfgb" :class="{ cover }" :title="title"> + <canvas ref="canvas" :width="size" :height="size" :title="title" v-if="!loaded"/> + <img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { decode } from 'blurhash'; + +export default defineComponent({ + props: { + src: { + type: String, + required: false, + default: null + }, + hash: { + type: String, + required: true + }, + alt: { + type: String, + required: false, + default: '', + }, + title: { + type: String, + required: false, + default: null, + }, + size: { + type: Number, + required: false, + default: 64 + }, + cover: { + type: Boolean, + required: false, + default: true, + } + }, + + data() { + return { + loaded: false, + }; + }, + + mounted() { + this.draw(); + }, + + methods: { + draw() { + if (this.hash == null) return; + const pixels = decode(this.hash, this.size, this.size); + const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d'); + const imageData = ctx!.createImageData(this.size, this.size); + imageData.data.set(pixels); + ctx!.putImageData(imageData, 0, 0); + }, + + onLoad() { + this.loaded = true; + } + } +}); +</script> + +<style lang="scss" scoped> +.xubzgfgb { + position: relative; + width: 100%; + height: 100%; + + > canvas, + > img { + display: block; + width: 100%; + height: 100%; + } + + > canvas { + position: absolute; + object-fit: cover; + } + + > img { + object-fit: contain; + } + + &.cover { + > img { + object-fit: cover; + } + } +} +</style> diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts new file mode 100644 index 0000000000..2340b228f8 --- /dev/null +++ b/packages/client/src/components/index.ts @@ -0,0 +1,37 @@ +import { App } from 'vue'; + +import mfm from './global/misskey-flavored-markdown.vue'; +import a from './global/a.vue'; +import acct from './global/acct.vue'; +import avatar from './global/avatar.vue'; +import emoji from './global/emoji.vue'; +import userName from './global/user-name.vue'; +import ellipsis from './global/ellipsis.vue'; +import time from './global/time.vue'; +import url from './global/url.vue'; +import i18n from './global/i18n'; +import loading from './global/loading.vue'; +import error from './global/error.vue'; +import ad from './global/ad.vue'; +import header from './global/header.vue'; +import spacer from './global/spacer.vue'; +import stickyContainer from './global/sticky-container.vue'; + +export default function(app: App) { + app.component('I18n', i18n); + app.component('Mfm', mfm); + app.component('MkA', a); + app.component('MkAcct', acct); + app.component('MkAvatar', avatar); + app.component('MkEmoji', emoji); + app.component('MkUserName', userName); + app.component('MkEllipsis', ellipsis); + app.component('MkTime', time); + app.component('MkUrl', url); + app.component('MkLoading', loading); + app.component('MkError', error); + app.component('MkAd', ad); + app.component('MkHeader', header); + app.component('MkSpacer', spacer); + app.component('MkStickyContainer', stickyContainer); +} diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue new file mode 100644 index 0000000000..bc62998a4a --- /dev/null +++ b/packages/client/src/components/instance-stats.vue @@ -0,0 +1,80 @@ +<template> +<div class="zbcjwnqg" style="margin-top: -8px;"> + <div class="selects" style="display: flex;"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="$ts.federation"> + <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option> + <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option> + </optgroup> + <optgroup :label="$ts.users"> + <option value="users">{{ $ts._charts.usersIncDec }}</option> + <option value="users-total">{{ $ts._charts.usersTotal }}</option> + <option value="active-users">{{ $ts._charts.activeUsers }}</option> + </optgroup> + <optgroup :label="$ts.notes"> + <option value="notes">{{ $ts._charts.notesIncDec }}</option> + <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> + <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> + <option value="notes-total">{{ $ts._charts.notesTotal }}</option> + </optgroup> + <optgroup :label="$ts.drive"> + <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> + <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option> + <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> + <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref, watch } from 'vue'; +import MkSelect from '@/components/form/select.vue'; +import MkChart from '@/components/chart.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + MkSelect, + MkChart, + }, + + props: { + chartLimit: { + type: Number, + required: false, + default: 90 + }, + detailed: { + type: Boolean, + required: false, + default: false + }, + }, + + setup() { + const chartSpan = ref<'hour' | 'day'>('hour'); + const chartSrc = ref('notes'); + + return { + chartSrc, + chartSpan, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.zbcjwnqg { + > .selects { + padding: 8px 16px 0 16px; + } +} +</style> diff --git a/packages/client/src/components/instance-ticker.vue b/packages/client/src/components/instance-ticker.vue new file mode 100644 index 0000000000..1ce5a1c2c1 --- /dev/null +++ b/packages/client/src/components/instance-ticker.vue @@ -0,0 +1,62 @@ +<template> +<div class="hpaizdrt" :style="bg"> + <img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/> + <span class="name">{{ info.name }}</span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { instanceName } from '@/config'; + +export default defineComponent({ + props: { + instance: { + type: Object, + required: false + }, + }, + + data() { + return { + info: this.instance || { + faviconUrl: '/favicon.ico', + name: instanceName, + themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content + } + } + }, + + computed: { + bg(): any { + const themeColor = this.info.themeColor || '#777777'; + return { + background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})` + }; + } + } +}); +</script> + +<style lang="scss" scoped> +.hpaizdrt { + $height: 1.1rem; + + height: $height; + border-radius: 4px 0 0 4px; + overflow: hidden; + color: #fff; + + > .icon { + height: 100%; + } + + > .name { + margin-left: 4px; + line-height: $height; + font-size: 0.9em; + vertical-align: top; + font-weight: bold; + } +} +</style> diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue new file mode 100644 index 0000000000..09f5f89f90 --- /dev/null +++ b/packages/client/src/components/launch-pad.vue @@ -0,0 +1,152 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="szkkfdyq _popup"> + <div class="main"> + <template v-for="item in items"> + <button v-if="item.action" class="_button" @click="$event => { item.action($event); close(); }" v-click-anime> + <i class="icon" :class="item.icon"></i> + <div class="text">{{ item.text }}</div> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + <MkA v-else :to="item.to" @click.passive="close()" v-click-anime> + <i class="icon" :class="item.icon"></i> + <div class="text">{{ item.text }}</div> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </MkA> + </template> + </div> + <div class="sub"> + <a href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()" v-click-anime> + <i class="fas fa-question-circle icon"></i> + <div class="text">{{ $ts.help }}</div> + </a> + <MkA to="/about" @click.passive="close()" v-click-anime> + <i class="fas fa-info-circle icon"></i> + <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div> + </MkA> + <MkA to="/about-misskey" @click.passive="close()" v-click-anime> + <img src="/static-assets/favicon.png" class="icon"/> + <div class="text">{{ $ts.aboutMisskey }}</div> + </MkA> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import { menuDef } from '@/menu'; +import { instanceName } from '@/config'; + +export default defineComponent({ + components: { + MkModal, + }, + + emits: ['closed'], + + data() { + return { + menuDef: menuDef, + items: [], + instanceName, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + }, + + created() { + this.items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ + type: def.to ? 'link' : 'button', + text: this.$ts[def.title], + icon: def.icon, + to: def.to, + action: def.action, + indicate: def.indicated, + })); + }, + + methods: { + close() { + this.$refs.modal.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.szkkfdyq { + width: 100%; + max-height: 100%; + max-width: 800px; + padding: 32px; + box-sizing: border-box; + overflow: auto; + text-align: center; + border-radius: 16px; + + @media (max-width: 500px) { + padding: 16px; + } + + > .main, > .sub { + > * { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + vertical-align: bottom; + width: 128px; + height: 128px; + border-radius: var(--radius); + + @media (max-width: 500px) { + width: 100px; + height: 100px; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + text-decoration: none; + } + + > .icon { + font-size: 26px; + height: 32px; + } + + > .text { + margin-top: 8px; + font-size: 0.9em; + line-height: 1.5em; + } + + > .indicator { + position: absolute; + top: 32px; + left: 32px; + color: var(--indicator); + font-size: 8px; + animation: blink 1s infinite; + + @media (max-width: 500px) { + top: 16px; + left: 16px; + } + } + } + } + + > .sub { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/client/src/components/link.vue b/packages/client/src/components/link.vue new file mode 100644 index 0000000000..a8e096e0a0 --- /dev/null +++ b/packages/client/src/components/link.vue @@ -0,0 +1,92 @@ +<template> +<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" + @mouseover="onMouseover" + @mouseleave="onMouseleave" + :title="url" +> + <slot></slot> + <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> +</component> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { url as local } from '@/config'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + url: { + type: String, + required: true, + }, + rel: { + type: String, + required: false, + } + }, + data() { + const self = this.url.startsWith(local); + return { + local, + self: self, + attr: self ? 'to' : 'href', + target: self ? null : '_blank', + showTimer: null, + hideTimer: null, + checkTimer: null, + close: null, + }; + }, + methods: { + async showPreview() { + if (!document.body.contains(this.$el)) return; + if (this.close) return; + + const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), { + url: this.url, + source: this.$el + }); + + this.close = () => { + dispose(); + }; + + this.checkTimer = setInterval(() => { + if (!document.body.contains(this.$el)) this.closePreview(); + }, 1000); + }, + closePreview() { + if (this.close) { + clearInterval(this.checkTimer); + this.close(); + this.close = null; + } + }, + onMouseover() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.showTimer = setTimeout(this.showPreview, 500); + }, + onMouseleave() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.hideTimer = setTimeout(this.closePreview, 500); + } + } +}); +</script> + +<style lang="scss" scoped> +.xlcxczvw { + word-break: break-all; + + > .icon { + padding-left: 2px; + font-size: .9em; + } +} +</style> diff --git a/packages/client/src/components/media-banner.vue b/packages/client/src/components/media-banner.vue new file mode 100644 index 0000000000..2cf8c772e5 --- /dev/null +++ b/packages/client/src/components/media-banner.vue @@ -0,0 +1,107 @@ +<template> +<div class="mk-media-banner"> + <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> + <span class="icon"><i class="fas fa-exclamation-triangle"></i></span> + <b>{{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> + <div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'"> + <audio class="audio" + :src="media.url" + :title="media.name" + controls + ref="audio" + @volumechange="volumechange" + preload="metadata" /> + </div> + <a class="download" v-else + :href="media.url" + :title="media.name" + :download="media.name" + > + <span class="icon"><i class="fas fa-download"></i></span> + <b>{{ media.name }}</b> + </a> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; + +export default defineComponent({ + props: { + media: { + type: Object, + required: true + } + }, + data() { + return { + hide: true, + }; + }, + mounted() { + const audioTag = this.$refs.audio as HTMLAudioElement; + if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); + }, + methods: { + volumechange() { + const audioTag = this.$refs.audio as HTMLAudioElement; + ColdDeviceStorage.set('mediaVolume', audioTag.volume); + }, + }, +}) +</script> + +<style lang="scss" scoped> +.mk-media-banner { + width: 100%; + border-radius: 4px; + margin-top: 4px; + overflow: hidden; + + > .download, + > .sensitive { + display: flex; + align-items: center; + font-size: 12px; + padding: 8px 12px; + white-space: nowrap; + + > * { + display: block; + } + + > b { + overflow: hidden; + text-overflow: ellipsis; + } + + > *:not(:last-child) { + margin-right: .2em; + } + + > .icon { + font-size: 1.6em; + } + } + + > .download { + background: var(--noteAttachedFile); + } + + > .sensitive { + background: #111; + color: #fff; + } + + > .audio { + .audio { + display: block; + width: 100%; + } + } +} +</style> diff --git a/packages/client/src/components/media-caption.vue b/packages/client/src/components/media-caption.vue new file mode 100644 index 0000000000..08a3ca2b4c --- /dev/null +++ b/packages/client/src/components/media-caption.vue @@ -0,0 +1,259 @@ +<template> + <MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> + <div class="container"> + <div class="fullwidth top-caption"> + <div class="mk-dialog"> + <header> + <Mfm v-if="title" class="title" :text="title"/> + <span class="text-count" :class="{ over: remainingLength < 0 }">{{ remainingLength }}</span> + </header> + <textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea> + <div class="buttons" v-if="(showOkButton || showCancelButton)"> + <MkButton inline @click="ok" primary :disabled="remainingLength < 0">{{ $ts.ok }}</MkButton> + <MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton> + </div> + </div> + </div> + <div class="hdrwpsaf fullwidth"> + <header>{{ image.name }}</header> + <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> + <footer> + <span>{{ image.type }}</span> + <span>{{ bytes(image.size) }}</span> + <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> + </footer> + </div> + </div> + </MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { length } from 'stringz'; +import MkModal from '@/components/ui/modal.vue'; +import MkButton from '@/components/ui/button.vue'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkModal, + MkButton, + }, + + props: { + image: { + type: Object, + required: true, + }, + title: { + type: String, + required: false + }, + input: { + required: true + }, + showOkButton: { + type: Boolean, + default: true + }, + showCancelButton: { + type: Boolean, + default: true + }, + cancelableByBgClick: { + type: Boolean, + default: true + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + inputValue: this.input.default ? this.input.default : null + }; + }, + + computed: { + remainingLength(): number { + if (typeof this.inputValue != "string") return 512; + return 512 - length(this.inputValue); + } + }, + + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + + beforeUnmount() { + document.removeEventListener('keydown', this.onKeydown); + }, + + methods: { + bytes, + number, + + done(canceled, result?) { + this.$emit('done', { canceled, result }); + this.$refs.modal.close(); + }, + + async ok() { + if (!this.showOkButton) return; + + const result = this.inputValue; + this.done(false, result); + }, + + cancel() { + this.done(true); + }, + + onBgClick() { + if (this.cancelableByBgClick) { + this.cancel(); + } + }, + + onKeydown(e) { + if (e.which === 27) { // ESC + this.cancel(); + } + }, + + onInputKeydown(e) { + if (e.which === 13) { // Enter + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } + } +}); +</script> + +<style lang="scss" scoped> +.container { + display: flex; + width: 100%; + height: 100%; + flex-direction: row; +} +@media (max-width: 850px) { + .container { + flex-direction: column; + } + .top-caption { + padding-bottom: 8px; + } +} +.fullwidth { + width: 100%; + margin: auto; +} +.mk-dialog { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + margin: auto; + + > header { + margin: 0 0 8px 0; + position: relative; + + > .title { + font-weight: bold; + font-size: 20px; + } + + > .text-count { + opacity: 0.7; + position: absolute; + right: 0; + } + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } + + > textarea { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + max-width: 100%; + min-width: 100%; + min-height: 90px; + + &:focus-visible { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } +} +.hdrwpsaf { + display: flex; + flex-direction: column; + height: 100%; + + > header, + > footer { + align-self: center; + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + flex: 1; + min-height: 0; + object-fit: contain; + width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } +} +</style> diff --git a/packages/client/src/components/media-image.vue b/packages/client/src/components/media-image.vue new file mode 100644 index 0000000000..8843b63207 --- /dev/null +++ b/packages/client/src/components/media-image.vue @@ -0,0 +1,155 @@ +<template> +<div class="qjewsnkg" v-if="hide" @click="hide = false"> + <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> + <div class="text"> + <div> + <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> + </div> +</div> +<div class="gqnyydlz" :style="{ background: color }" v-else> + <a + :href="image.url" + :title="image.name" + > + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> + <div class="gif" v-if="image.type === 'image/gif'">GIF</div> + </a> + <i class="fas fa-eye-slash" @click="hide = true"></i> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; +import ImageViewer from './image-viewer.vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + ImgWithBlurhash + }, + props: { + image: { + type: Object, + required: true + }, + raw: { + default: false + } + }, + data() { + return { + hide: true, + color: null, + }; + }, + computed: { + url(): any { + let url = this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(this.image.thumbnailUrl) + : this.image.thumbnailUrl; + + if (this.raw || this.$store.state.loadRawImages) { + url = this.image.url; + } + + return url; + } + }, + created() { + // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする + this.$watch('image', () => { + this.hide = (this.$store.state.nsfw === 'force') ? true : this.image.isSensitive && (this.$store.state.nsfw !== 'ignore'); + if (this.image.blurhash) { + this.color = extractAvgColorFromBlurhash(this.image.blurhash); + } + }, { + deep: true, + immediate: true, + }); + }, +}); +</script> + +<style lang="scss" scoped> +.qjewsnkg { + position: relative; + + > .bg { + filter: brightness(0.5); + } + + > .text { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + + > div { + display: table-cell; + text-align: center; + font-size: 0.8em; + color: #fff; + + > * { + display: block; + } + } + } +} + +.gqnyydlz { + position: relative; + border: solid 0.5px var(--divider); + + > i { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 14px; + opacity: .5; + padding: 3px 6px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; + } + + > a { + display: block; + cursor: zoom-in; + overflow: hidden; + width: 100%; + height: 100%; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + + > .gif { + background-color: var(--fg); + border-radius: 6px; + color: var(--accentLighten); + display: inline-block; + font-size: 14px; + font-weight: bold; + left: 12px; + opacity: .5; + padding: 0 6px; + text-align: center; + top: 12px; + pointer-events: none; + } + } +} +</style> diff --git a/packages/client/src/components/media-list.vue b/packages/client/src/components/media-list.vue new file mode 100644 index 0000000000..51eaa86f35 --- /dev/null +++ b/packages/client/src/components/media-list.vue @@ -0,0 +1,167 @@ +<template> +<div class="hoawjimk"> + <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :media="media" :key="media.id"/> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> + <div :data-count="mediaList.filter(media => previewable(media)).length" ref="gallery"> + <template v-for="media in mediaList"> + <XVideo :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> + <XImage class="image" :data-id="media.id" :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, ref } from 'vue'; +import * as misskey from 'misskey-js'; +import PhotoSwipeLightbox from 'photoswipe/dist/photoswipe-lightbox.esm.js'; +import PhotoSwipe from 'photoswipe/dist/photoswipe.esm.js'; +import 'photoswipe/dist/photoswipe.css'; +import XBanner from './media-banner.vue'; +import XImage from './media-image.vue'; +import XVideo from './media-video.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + XBanner, + XImage, + XVideo, + }, + props: { + mediaList: { + type: Array as PropType<misskey.entities.DriveFile[]>, + required: true, + }, + raw: { + default: false + }, + }, + setup(props) { + const gallery = ref(null); + + onMounted(() => { + const lightbox = new PhotoSwipeLightbox({ + dataSource: props.mediaList.filter(media => media.type.startsWith('image')).map(media => ({ + src: media.url, + w: media.properties.width, + h: media.properties.height, + alt: media.name, + })), + gallery: gallery.value, + children: '.image', + thumbSelector: '.image', + pswpModule: PhotoSwipe + }); + + lightbox.on('itemData', (e) => { + const { itemData } = e; + + // element is children + const { element } = itemData; + + const id = element.dataset.id; + const file = props.mediaList.find(media => media.id === id); + + itemData.src = file.url; + itemData.w = Number(file.properties.width); + itemData.h = Number(file.properties.height); + itemData.msrc = file.thumbnailUrl; + itemData.thumbCropped = true; + }); + + lightbox.init(); + }); + + const previewable = (file: misskey.entities.DriveFile): boolean => { + return file.type.startsWith('video') || file.type.startsWith('image'); + }; + + return { + previewable, + gallery, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.hoawjimk { + > .gird-container { + position: relative; + width: 100%; + margin-top: 4px; + + &:before { + content: ''; + display: block; + padding-top: 56.25% // 16:9; + } + + > div { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-gap: 4px; + + > * { + overflow: hidden; + border-radius: 6px; + } + + &[data-count="1"] { + grid-template-rows: 1fr; + } + + &[data-count="2"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + } + + &[data-count="3"] { + grid-template-columns: 1fr 0.5fr; + grid-template-rows: 1fr 1fr; + + > *:nth-child(1) { + grid-row: 1 / 3; + } + + > *:nth-child(3) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + + &[data-count="4"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + } + + > *:nth-child(1) { + grid-column: 1 / 2; + grid-row: 1 / 2; + } + + > *:nth-child(2) { + grid-column: 2 / 3; + grid-row: 1 / 2; + } + + > *:nth-child(3) { + grid-column: 1 / 2; + grid-row: 2 / 3; + } + + > *:nth-child(4) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + } +} +</style> diff --git a/packages/client/src/components/media-video.vue b/packages/client/src/components/media-video.vue new file mode 100644 index 0000000000..aa885bd564 --- /dev/null +++ b/packages/client/src/components/media-video.vue @@ -0,0 +1,97 @@ +<template> +<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="hide" @click="hide = false"> + <div> + <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> +</div> +<div class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else> + <video + :poster="video.thumbnailUrl" + :title="video.name" + preload="none" + controls + @contextmenu.stop + > + <source + :src="video.url" + :type="video.type" + > + </video> + <i class="fas fa-eye-slash" @click="hide = true"></i> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + video: { + type: Object, + required: true + } + }, + data() { + return { + hide: true, + }; + }, + created() { + this.hide = (this.$store.state.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.nsfw !== 'ignore'); + }, +}); +</script> + +<style lang="scss" scoped> +.kkjnbbplepmiyuadieoenjgutgcmtsvu { + position: relative; + + > i { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 14px; + opacity: .5; + padding: 3px 6px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; + } + + > video { + display: flex; + justify-content: center; + align-items: center; + + font-size: 3.5em; + overflow: hidden; + background-position: center; + background-size: cover; + width: 100%; + height: 100%; + } +} + +.icozogqfvdetwohsdglrbswgrejoxbdj { + display: flex; + justify-content: center; + align-items: center; + background: #111; + color: #fff; + + > div { + display: table-cell; + text-align: center; + font-size: 12px; + + > b { + display: block; + } + } +} +</style> diff --git a/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue new file mode 100644 index 0000000000..a5be3fab22 --- /dev/null +++ b/packages/client/src/components/mention.vue @@ -0,0 +1,84 @@ +<template> +<MkA v-if="url.startsWith('/')" class="ldlomzub" :class="{ isMe }" :to="url" v-user-preview="canonical" :style="{ background: bg }"> + <img class="icon" :src="`/avatar/@${username}@${host}`" alt=""> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host" v-if="(host != localHost) || $store.state.showFullAcct">@{{ toUnicode(host) }}</span> + </span> +</MkA> +<a v-else class="ldlomzub" :href="url" target="_blank" rel="noopener" :style="{ background: bg }"> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host">@{{ toUnicode(host) }}</span> + </span> +</a> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import { toUnicode } from 'punycode'; +import { host as localHost } from '@/config'; +import { $i } from '@/account'; + +export default defineComponent({ + props: { + username: { + type: String, + required: true + }, + host: { + type: String, + required: true + } + }, + + setup(props) { + const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; + + const url = `/${canonical}`; + + const isMe = $i && ( + `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() + ); + + const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); + bg.setAlpha(0.1); + + return { + localHost, + isMe, + url, + canonical, + toUnicode, + bg: bg.toRgbString(), + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ldlomzub { + display: inline-block; + padding: 4px 8px 4px 4px; + border-radius: 999px; + color: var(--mention); + + &.isMe { + color: var(--mentionMe); + } + + > .icon { + width: 1.5em; + margin: 0 0.2em 0 0; + vertical-align: bottom; + border-radius: 100%; + } + + > .main { + > .host { + opacity: 0.5; + } + } +} +</style> diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts new file mode 100644 index 0000000000..d41cf6fc2b --- /dev/null +++ b/packages/client/src/components/mfm.ts @@ -0,0 +1,321 @@ +import { VNode, defineComponent, h } from 'vue'; +import * as mfm from 'mfm-js'; +import MkUrl from '@/components/global/url.vue'; +import MkLink from '@/components/link.vue'; +import MkMention from '@/components/mention.vue'; +import MkEmoji from '@/components/global/emoji.vue'; +import { concat } from '@/scripts/array'; +import MkFormula from '@/components/formula.vue'; +import MkCode from '@/components/code.vue'; +import MkGoogle from '@/components/google.vue'; +import MkSparkle from '@/components/sparkle.vue'; +import MkA from '@/components/global/a.vue'; +import { host } from '@/config'; +import { MFM_TAGS } from '@/scripts/mfm-tags'; + +export default defineComponent({ + props: { + text: { + type: String, + required: true + }, + plain: { + type: Boolean, + default: false + }, + nowrap: { + type: Boolean, + default: false + }, + author: { + type: Object, + default: null + }, + i: { + type: Object, + default: null + }, + customEmojis: { + required: false, + }, + isNote: { + type: Boolean, + default: true + }, + }, + + render() { + if (this.text == null || this.text == '') return; + + const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text, { fnNameList: MFM_TAGS }); + + const validTime = (t: string | null | undefined) => { + if (t == null) return null; + return t.match(/^[0-9.]+s$/) ? t : null; + }; + + const genEl = (ast: mfm.MfmNode[]) => concat(ast.map((token): VNode[] => { + switch (token.type) { + case 'text': { + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + + if (!this.plain) { + const res = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children))]; + } + + case 'strike': { + return [h('del', genEl(token.children))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;' + }, genEl(token.children)); + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style; + switch (token.props.name) { + case 'tada': { + style = `font-size: 150%;` + (this.$store.state.animatedMfm ? 'animation: tada 1s linear infinite both;' : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) || '1s'; + style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) || '1.5s'; + style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + break; + } + case 'jump': { + style = this.$store.state.animatedMfm ? 'animation: mfm-jump 0.75s linear infinite;' : ''; + break; + } + case 'bounce': { + style = this.$store.state.animatedMfm ? 'animation: mfm-bounce 0.75s linear infinite; transform-origin: center bottom;' : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + style = `font-size: 200%;`; + break; + } + case 'x3': { + style = `font-size: 400%;`; + break; + } + case 'x4': { + style = `font-size: 600%;`; + break; + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children)); + } + case 'rainbow': { + style = this.$store.state.animatedMfm ? 'animation: mfm-rainbow 1s linear infinite;' : ''; + break; + } + case 'sparkle': { + if (!this.$store.state.animatedMfm) { + return genEl(token.children); + } + let count = token.props.args.count ? parseInt(token.props.args.count) : 10; + if (count > 100) { + count = 100; + } + const speed = token.props.args.speed ? parseFloat(token.props.args.speed) : 1; + return h(MkSparkle, { + count, speed, + }, genEl(token.children)); + } + } + if (style == null) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); + } else { + return h('span', { + style: 'display: inline-block;' + style, + }, genEl(token.children)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;' + }, genEl(token.children))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;' + }, genEl(token.children))]; + } + + case 'url': { + return [h(MkUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(MkLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children))]; + } + + case 'mention': { + return [h(MkMention, { + key: Math.random(), + host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, + username: token.props.username + })]; + } + + case 'hashtag': { + return [h(MkA, { + key: Math.random(), + to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);' + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + lang: token.props.lang, + })]; + } + + case 'inlineCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + inline: true + })]; + } + + case 'quote': { + if (!this.nowrap) { + return [h('div', { + class: 'quote' + }, genEl(token.children))]; + } else { + return [h('span', { + class: 'quote' + }, genEl(token.children))]; + } + } + + case 'emojiCode': { + return [h(MkEmoji, { + key: Math.random(), + emoji: `:${token.props.name}:`, + customEmojis: this.customEmojis, + normal: this.plain + })]; + } + + case 'unicodeEmoji': { + return [h(MkEmoji, { + key: Math.random(), + emoji: token.props.emoji, + customEmojis: this.customEmojis, + normal: this.plain + })]; + } + + case 'mathInline': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: false + })]; + } + + case 'mathBlock': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: true + })]; + } + + case 'search': { + return [h(MkGoogle, { + key: Math.random(), + q: token.props.query + })]; + } + + default: { + console.error('unrecognized ast type:', token.type); + + return []; + } + } + })); + + // Parse ast to DOM + return h('span', genEl(ast)); + } +}); diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue new file mode 100644 index 0000000000..2eb9ae8cbe --- /dev/null +++ b/packages/client/src/components/mini-chart.vue @@ -0,0 +1,90 @@ +<template> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible"> + <defs> + <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> + </linearGradient> + <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="polygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="polylinePoints" + fill="none" + stroke="#fff" + stroke-width="2"/> + <circle + :cx="headX" + :cy="headY" + r="3" + fill="#fff"/> + </mask> + </defs> + <rect + x="-10" y="-10" + :width="viewBoxX + 20" :height="viewBoxY + 20" + :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/> +</svg> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + src: { + type: Array, + required: true + } + }, + data() { + return { + viewBoxX: 50, + viewBoxY: 30, + gradientId: uuid(), + maskId: uuid(), + polylinePoints: '', + polygonPoints: '', + headX: null, + headY: null, + clock: null + }; + }, + watch: { + src() { + this.draw(); + } + }, + created() { + this.draw(); + + // Vueが何故かWatchを発動させない場合があるので + this.clock = setInterval(this.draw, 1000); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + draw() { + const stats = this.src.slice().reverse(); + const peak = Math.max.apply(null, stats) || 1; + + const polylinePoints = stats.map((n, i) => [ + i * (this.viewBoxX / (stats.length - 1)), + (1 - (n / peak)) * this.viewBoxY + ]); + + this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + + this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + + this.headX = polylinePoints[polylinePoints.length - 1][0]; + this.headY = polylinePoints[polylinePoints.length - 1][1]; + } + } +}); +</script> diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue new file mode 100644 index 0000000000..2086736683 --- /dev/null +++ b/packages/client/src/components/modal-page-window.vue @@ -0,0 +1,223 @@ +<template> +<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> + <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> + <div class="header" @contextmenu="onContextmenu"> + <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> + <span v-else style="display: inline-block; width: 20px"></span> + <span v-if="pageInfo" class="title"> + <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i> + <span>{{ pageInfo.title }}</span> + </span> + <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button> + </div> + <div class="body"> + <MkStickyContainer> + <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> + <keep-alive> + <component :is="component" v-bind="props" :ref="changePage"/> + </keep-alive> + </MkStickyContainer> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import { popout } from '@/scripts/popout'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; +import * as symbols from '@/symbols'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkModal, + }, + + inject: { + sideViewHook: { + default: null + } + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + }, + shouldHeaderThin: true, + }; + }, + + props: { + initialPath: { + type: String, + required: true, + }, + initialComponent: { + type: Object, + required: true, + }, + initialProps: { + type: Object, + required: false, + default: () => {}, + }, + }, + + emits: ['closed'], + + data() { + return { + width: 860, + height: 660, + pageInfo: null, + path: this.initialPath, + component: this.initialComponent, + props: this.initialProps, + history: [], + }; + }, + + computed: { + url(): string { + return url + this.path; + }, + + contextmenu() { + return [{ + type: 'label', + text: this.path, + }, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: this.expand + }, this.sideViewHook ? { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.sideViewHook(this.path); + this.$refs.window.close(); + } + } : undefined, { + icon: 'fas fa-external-link-alt', + text: this.$ts.popout, + action: this.popout + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.$refs.window.close(); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }]; + }, + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + navigate(path, record = true) { + if (record) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + expand() { + this.$router.push(this.path); + this.$refs.window.close(); + }, + + popout() { + popout(this.path, this.$el); + this.$refs.window.close(); + }, + + onContextmenu(e) { + os.contextMenu(this.contextmenu, e); + } + }, +}); +</script> + +<style lang="scss" scoped> +.hrmcaedk { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + + --root-margin: 24px; + + @media (max-width: 500px) { + --root-margin: 16px; + } + + > .header { + $height: 52px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + height: $height; + line-height: $height; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0px 1px var(--divider); + + > button { + height: $height; + width: $height; + + &:hover { + color: var(--fgHighlighted); + } + } + + @media (max-width: 500px) { + height: $height-narrow; + line-height: $height-narrow; + padding-left: 16px; + + > button { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + + > .icon { + margin-right: 0.5em; + } + } + } + + > .body { + overflow: auto; + background: var(--bg); + } +} +</style> diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue new file mode 100644 index 0000000000..8b6905a0e4 --- /dev/null +++ b/packages/client/src/components/note-detailed.vue @@ -0,0 +1,1229 @@ +<template> +<div + class="lxwezrsl _block" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" + v-size="{ max: [500, 450, 350, 300] }" +> + <XSub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="fas fa-retweet"></i> + <I18n :src="$ts.renotedBy" tag="span"> + <template #user> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> + <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <header class="header"> + <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/> + <div class="body"> + <div class="top"> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.user.id"> + <MkUserName :user="appearNote.user"/> + </MkA> + <span class="is-bot" v-if="appearNote.user.isBot">bot</span> + <span class="admin" v-if="appearNote.user.isAdmin"><i class="fas fa-bookmark"></i></span> + <span class="moderator" v-if="!appearNote.user.isAdmin && appearNote.user.isModerator"><i class="far fa-bookmark"></i></span> + <span class="visibility" v-if="appearNote.visibility !== 'public'"> + <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="appearNote.localOnly"><i class="fas fa-biohazard"></i></span> + </div> + <div class="username"><MkAcct :user="appearNote.user"/></div> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + </div> + </header> + <div class="main"> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model="showContent" :note="appearNote"/> + </p> + <div class="content" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + <div class="translation" v-if="translating || translation"> + <MkLoading v-if="translating" mini/> + <div class="translated" v-else> + <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b> + {{ translation.text }} + </div> + </div> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="true" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer class="footer"> + <div class="info"> + <span class="mobile" v-if="appearNote.viaMobile"><i class="fas fa-mobile-alt"></i></span> + <MkTime class="created-at" :time="appearNote.createdAt" mode="detail"/> + </div> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> + <template v-else><i class="fas fa-reply"></i></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> + <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <i class="fas fa-ban"></i> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <i class="fas fa-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <i class="fas fa-minus"></i> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <i class="fas fa-ellipsis-h"></i> + </button> + </footer> + </div> + </article> + <XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> +</div> +<div v-else class="_panel muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as mfm from 'mfm-js'; +import { sum } from '@/scripts/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNoteSimple from './note-simple.vue'; +import XReactionsViewer from './reactions-viewer.vue'; +import XMediaList from './media-list.vue'; +import XCwButton from './cw-button.vue'; +import XPoll from './poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +// TODO: note.vueとほぼ同じなので共通化したい +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNoteSimple, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + conversation: [], + replies: [], + showContent: false, + isDeleted: false, + muted: false, + translation: null, + translating: false, + }; + }, + + computed: { + rs() { + return this.$store.state.reactions; + }, + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$i && (this.$i.id === this.appearNote.userId); + }, + + isMyRenote(): boolean { + return this.$i && (this.$i.id === this.note.userId); + }, + + canRenote(): boolean { + return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + urls(): string[] { + if (this.appearNote.text) { + return extractUrlFromMfm(mfm.parse(this.appearNote.text)); + } else { + return null; + } + }, + + showTicker() { + if (this.$store.state.instanceTicker === 'always') return true; + if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; + return false; + } + }, + + async created() { + if (this.$i) { + this.connection = os.stream; + } + + this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); + + // plugin + if (noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); + } + this.$emit('update:note', Object.freeze(result)); + } + + os.api('notes/children', { + noteId: this.appearNote.id, + limit: 30 + }).then(replies => { + this.replies = replies; + }); + + if (this.appearNote.replyId) { + os.api('notes/conversation', { + noteId: this.appearNote.replyId + }).then(conversation => { + this.conversation = conversation.reverse(); + }); + } + }, + + mounted() { + this.capture(true); + + if (this.$i) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeUnmount() { + this.decapture(true); + + if (this.$i) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + updateAppearNote(v) { + this.$emit('update:note', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + + readPromo() { + os.api('promo/read', { + noteId: this.appearNote.id + }); + this.isDeleted = true; + }, + + capture(withHandler = false) { + if (this.$i) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$i) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + n.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Increment the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; + + if (body.userId === this.$i.id) { + n.myReaction = reaction; + } + + this.updateAppearNote(n); + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Decrement the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; + + if (body.userId === this.$i.id) { + n.myReaction = null; + } + + this.updateAppearNote(n); + break; + } + + case 'pollVoted': { + const choice = body.choice; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + const choices = [...this.appearNote.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...(body.userId === this.$i.id ? { + isVoted: true + } : {}) + }; + + n.poll = { + ...this.appearNote.poll, + choices: choices + }; + + this.updateAppearNote(n); + break; + } + + case 'deleted': { + this.isDeleted = true; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(); + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + os.popupMenu([{ + text: this.$ts.renote, + icon: 'fas fa-retweet', + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: 'fas fa-quote-right', + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }); + }, + + renoteDirectly() { + os.apiWithDialog('notes/create', { + renoteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.renoted, + }); + }, (e: Error) => { + if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { + os.dialog({ + type: 'error', + text: this.$ts.cantRenote, + }); + } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { + os.dialog({ + type: 'error', + text: this.$ts.cantReRenote, + }); + } + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + reactionPicker.show(this.$refs.reactButton, reaction => { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, () => { + this.focus(); + }); + }, + + reactDirectly(reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { + noteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.favorited, + }); + }, (e: Error) => { + if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { + os.dialog({ + type: 'error', + text: this.$ts.alreadyFavorited, + }); + } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { + os.dialog({ + type: 'error', + text: this.$ts.cantFavorite, + }); + } + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.noteDeleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + delEdit() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteAndEditConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + }); + }, + + toggleFavorite(favorite: boolean) { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.appearNote.id + }); + }, + + toggleWatch(watch: boolean) { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.appearNote.id + }); + }, + + toggleThreadMute(mute: boolean) { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: this.appearNote.id + }); + }, + + getMenu() { + let menu; + if (this.$i) { + const statePromise = os.api('notes/state', { + noteId: this.appearNote.id + }); + + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + { + icon: 'fas fa-share-alt', + text: this.$ts.share, + action: this.share + }, + this.$instance.translatorAvailable ? { + icon: 'fas fa-language', + text: this.$ts.translate, + action: this.translate + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: 'fas fa-star', + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: 'fas fa-paperclip', + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'fas fa-eye-slash', + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: 'fas fa-eye', + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: this.$ts.unmuteThread, + action: () => this.toggleThreadMute(false) + } : { + icon: 'fas fa-comment-slash', + text: this.$ts.muteThread, + action: () => this.toggleThreadMute(true) + }), + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: 'fas fa-thumbtack', + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: 'fas fa-edit', + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'fas fa-plug', + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } + }, + + menu(viaKeyboard = false) { + os.popupMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(this.focus); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.popupMenu([{ + text: this.$ts.unrenote, + icon: 'fas fa-trash-alt', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { + viaKeyboard: viaKeyboard + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + copyContent() { + copyToClipboard(this.appearNote.text); + os.success(); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.appearNote.id}`); + os.success(); + }, + + togglePin(pin: boolean) { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: this.appearNote.id + }, undefined, null, e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.dialog({ + type: 'error', + text: this.$ts.pinLimitExceeded + }); + } + }); + }, + + async clip() { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'fas fa-plus', + text: this.$ts.createNew, + action: async () => { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }))], this.$refs.menuButton, { + }).then(this.focus); + }, + + async promote() { + const { canceled, result: days } = await os.dialog({ + title: this.$ts.numberOfDays, + input: { type: 'number' } + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }); + }, + + share() { + navigator.share({ + title: this.$t('noteOf', { user: this.appearNote.user.name }), + text: this.appearNote.text, + url: `${url}/notes/${this.appearNote.id}` + }); + }, + + async translate() { + if (this.translation != null) return; + this.translating = true; + const res = await os.api('notes/translate', { + noteId: this.appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + this.translating = false; + this.translation = res; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.lxwezrsl { + position: relative; + transition: box-shadow 0.1s ease; + overflow: hidden; + contain: content; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .reply-to-more { + opacity: 0.7; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > i { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + padding: 32px; + font-size: 1.1em; + + > .header { + display: flex; + position: relative; + margin-bottom: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + width: 58px; + height: 58px; + } + + > .body { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 16px; + font-size: 0.95em; + + > .top { + > .name { + font-weight: bold; + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 0.5em; + padding: 4px 6px; + font-size: 80%; + border: solid 0.5px var(--divider); + border-radius: 4px; + } + + > .admin, + > .moderator { + margin-right: 0.5em; + color: var(--badge); + } + } + } + } + + > .main { + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + > .info { + margin: 16px 0; + opacity: 0.7; + font-size: 0.9em; + } + + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 16px; + + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} +</style> diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue new file mode 100644 index 0000000000..c61ec41dd1 --- /dev/null +++ b/packages/client/src/components/note-header.vue @@ -0,0 +1,115 @@ +<template> +<header class="kkwtjztg"> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> + <MkUserName :user="note.user"/> + </MkA> + <div class="is-bot" v-if="note.user.isBot">bot</div> + <div class="username"><MkAcct :user="note.user"/></div> + <div class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></div> + <div class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></div> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span> + <MkA class="created-at" :to="notePage(note)"> + <MkTime :time="note.createdAt"/> + </MkA> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> +</header> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import notePage from '@/filters/note'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + note: { + type: Object, + required: true + }, + }, + + data() { + return { + }; + }, + + methods: { + notePage, + userPage + } +}); +</script> + +<style lang="scss" scoped> +.kkwtjztg { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + flex-shrink: 1; + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + border: solid 0.5px var(--divider); + border-radius: 3px; + } + + > .admin, + > .moderator { + flex-shrink: 0; + margin-right: 0.5em; + color: var(--badge); + } + + > .username { + flex-shrink: 9999999; + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + } + + > .info { + flex-shrink: 0; + margin-left: auto; + font-size: 0.9em; + + > .mobile { + margin-right: 8px; + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } +} +</style> diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue new file mode 100644 index 0000000000..a474a01341 --- /dev/null +++ b/packages/client/src/components/note-preview.vue @@ -0,0 +1,98 @@ +<template> +<div class="fefdfafb" v-size="{ min: [350, 500] }"> + <MkAvatar class="avatar" :user="$i"/> + <div class="main"> + <div class="header"> + <MkUserName :user="$i"/> + </div> + <div class="body"> + <div class="content"> + <Mfm :text="text" :author="$i" :i="$i"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + components: { + }, + + props: { + text: { + type: String, + required: true + } + }, +}); +</script> + +<style lang="scss" scoped> +.fefdfafb { + display: flex; + margin: 0; + padding: 0; + overflow: clip; + font-size: 0.95em; + + &.min-width_350px { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } + + &.min-width_500px { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue new file mode 100644 index 0000000000..2f19bd6e0b --- /dev/null +++ b/packages/client/src/components/note-simple.vue @@ -0,0 +1,113 @@ +<template> +<div class="yohlumlk" v-size="{ min: [350, 500] }"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="main"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <XCwButton v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.yohlumlk { + display: flex; + margin: 0; + padding: 0; + overflow: clip; + font-size: 0.95em; + + &.min-width_350px { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } + + &.min-width_500px { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/note.sub.vue b/packages/client/src/components/note.sub.vue new file mode 100644 index 0000000000..45204854be --- /dev/null +++ b/packages/client/src/components/note.sub.vue @@ -0,0 +1,146 @@ +<template> +<div class="wrpstxzv" :class="{ children }" v-size="{ max: [450] }"> + <div class="main"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="body"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> + <XCwButton v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> + </div> + <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + name: 'XSub', + + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + children: { + type: Boolean, + required: false, + default: false + }, + // TODO + truncate: { + type: Boolean, + default: true + } + }, + + data() { + return { + showContent: false, + replies: [], + }; + }, + + created() { + if (this.detail) { + os.api('notes/children', { + noteId: this.note.id, + limit: 5 + }).then(replies => { + this.replies = replies; + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.wrpstxzv { + padding: 16px 32px; + font-size: 0.9em; + + &.max-width_450px { + padding: 14px 16px; + } + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + + &.max-width_450px { + padding: 10px 0 0 8px; + } + } + + > .main { + display: flex; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 38px; + height: 38px; + border-radius: 8px; + } + + > .body { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } + } + + > .reply { + border-left: solid 0.5px var(--divider); + margin-top: 10px; + } +} +</style> diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue new file mode 100644 index 0000000000..b1ec674b67 --- /dev/null +++ b/packages/client/src/components/note.vue @@ -0,0 +1,1228 @@ +<template> +<div + class="tkcbzcuz" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" + v-size="{ max: [500, 450, 350, 300] }" +> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> + <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> + <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="fas fa-retweet"></i> + <I18n :src="$ts.renotedBy" tag="span"> + <template #user> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> + <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <MkAvatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <XNoteHeader class="header" :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model="showContent" :note="appearNote"/> + </p> + <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + <div class="translation" v-if="translating || translation"> + <MkLoading v-if="translating" mini/> + <div class="translated" v-else> + <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b> + {{ translation.text }} + </div> + </div> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> + <button v-if="collapsed" class="fade _button" @click="collapsed = false"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer class="footer"> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> + <template v-else><i class="fas fa-reply"></i></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> + <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <i class="fas fa-ban"></i> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <i class="fas fa-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <i class="fas fa-minus"></i> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <i class="fas fa-ellipsis-h"></i> + </button> + </footer> + </div> + </article> +</div> +<div v-else class="muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as mfm from 'mfm-js'; +import { sum } from '@/scripts/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNoteSimple from './note-simple.vue'; +import XReactionsViewer from './reactions-viewer.vue'; +import XMediaList from './media-list.vue'; +import XCwButton from './cw-button.vue'; +import XPoll from './poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNoteSimple, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + replies: [], + showContent: false, + collapsed: false, + isDeleted: false, + muted: false, + translation: null, + translating: false, + }; + }, + + computed: { + rs() { + return this.$store.state.reactions; + }, + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$i && (this.$i.id === this.appearNote.userId); + }, + + isMyRenote(): boolean { + return this.$i && (this.$i.id === this.note.userId); + }, + + canRenote(): boolean { + return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + urls(): string[] { + if (this.appearNote.text) { + return extractUrlFromMfm(mfm.parse(this.appearNote.text)); + } else { + return null; + } + }, + + showTicker() { + if (this.$store.state.instanceTicker === 'always') return true; + if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; + return false; + } + }, + + async created() { + if (this.$i) { + this.connection = os.stream; + } + + this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( + (this.appearNote.text.split('\n').length > 9) || + (this.appearNote.text.length > 500) + ); + this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); + + // plugin + if (noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); + } + this.$emit('update:note', Object.freeze(result)); + } + }, + + mounted() { + this.capture(true); + + if (this.$i) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeUnmount() { + this.decapture(true); + + if (this.$i) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + updateAppearNote(v) { + this.$emit('update:note', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + + readPromo() { + os.api('promo/read', { + noteId: this.appearNote.id + }); + this.isDeleted = true; + }, + + capture(withHandler = false) { + if (this.$i) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$i) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + n.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Increment the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; + + if (body.userId === this.$i.id) { + n.myReaction = reaction; + } + + this.updateAppearNote(n); + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Decrement the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; + + if (body.userId === this.$i.id) { + n.myReaction = null; + } + + this.updateAppearNote(n); + break; + } + + case 'pollVoted': { + const choice = body.choice; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + const choices = [...this.appearNote.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...(body.userId === this.$i.id ? { + isVoted: true + } : {}) + }; + + n.poll = { + ...this.appearNote.poll, + choices: choices + }; + + this.updateAppearNote(n); + break; + } + + case 'deleted': { + this.isDeleted = true; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(); + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + os.popupMenu([{ + text: this.$ts.renote, + icon: 'fas fa-retweet', + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: 'fas fa-quote-right', + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }); + }, + + renoteDirectly() { + os.apiWithDialog('notes/create', { + renoteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.renoted, + }); + }, (e: Error) => { + if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { + os.dialog({ + type: 'error', + text: this.$ts.cantRenote, + }); + } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { + os.dialog({ + type: 'error', + text: this.$ts.cantReRenote, + }); + } + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + reactionPicker.show(this.$refs.reactButton, reaction => { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, () => { + this.focus(); + }); + }, + + reactDirectly(reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { + noteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.favorited, + }); + }, (e: Error) => { + if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { + os.dialog({ + type: 'error', + text: this.$ts.alreadyFavorited, + }); + } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { + os.dialog({ + type: 'error', + text: this.$ts.cantFavorite, + }); + } + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.noteDeleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + delEdit() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteAndEditConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + }); + }, + + toggleFavorite(favorite: boolean) { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.appearNote.id + }); + }, + + toggleWatch(watch: boolean) { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.appearNote.id + }); + }, + + toggleThreadMute(mute: boolean) { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: this.appearNote.id + }); + }, + + getMenu() { + let menu; + if (this.$i) { + const statePromise = os.api('notes/state', { + noteId: this.appearNote.id + }); + + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + { + icon: 'fas fa-share-alt', + text: this.$ts.share, + action: this.share + }, + this.$instance.translatorAvailable ? { + icon: 'fas fa-language', + text: this.$ts.translate, + action: this.translate + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: 'fas fa-star', + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: 'fas fa-paperclip', + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'fas fa-eye-slash', + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: 'fas fa-eye', + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: this.$ts.unmuteThread, + action: () => this.toggleThreadMute(false) + } : { + icon: 'fas fa-comment-slash', + text: this.$ts.muteThread, + action: () => this.toggleThreadMute(true) + }), + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: 'fas fa-thumbtack', + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: 'fas fa-edit', + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'fas fa-plug', + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } + }, + + menu(viaKeyboard = false) { + os.popupMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(this.focus); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.popupMenu([{ + text: this.$ts.unrenote, + icon: 'fas fa-trash-alt', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { + viaKeyboard: viaKeyboard + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + copyContent() { + copyToClipboard(this.appearNote.text); + os.success(); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.appearNote.id}`); + os.success(); + }, + + togglePin(pin: boolean) { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: this.appearNote.id + }, undefined, null, e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.dialog({ + type: 'error', + text: this.$ts.pinLimitExceeded + }); + } + }); + }, + + async clip() { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'fas fa-plus', + text: this.$ts.createNew, + action: async () => { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }))], this.$refs.menuButton, { + }).then(this.focus); + }, + + async promote() { + const { canceled, result: days } = await os.dialog({ + title: this.$ts.numberOfDays, + input: { type: 'number' } + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }); + }, + + share() { + navigator.share({ + title: this.$t('noteOf', { user: this.appearNote.user.name }), + text: this.appearNote.text, + url: `${url}/notes/${this.appearNote.id}` + }); + }, + + async translate() { + if (this.translation != null) return; + this.translating = true; + const res = await os.api('notes/translate', { + noteId: this.appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + this.translating = false; + this.translation = res; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.tkcbzcuz { + position: relative; + transition: box-shadow 0.1s ease; + overflow: clip; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > .info { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; + + > i { + margin-right: 4px; + } + + > .hide { + margin-left: auto; + color: inherit; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > i { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 28px 32px 18px; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 14px 8px 0; + width: 58px; + height: 58px; + position: sticky; + top: calc(22px + var(--stickyTop, 0px)); + left: 0; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + &.collapsed { + position: relative; + max-height: 9em; + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .info { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 14px 16px 9px; + + > .avatar { + margin: 0 10px 8px 0; + width: 50px; + height: 50px; + top: calc(14px + var(--stickyTop, 0px)); + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .avatar { + width: 44px; + height: 44px; + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} +</style> diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue new file mode 100644 index 0000000000..1e7da7a2b0 --- /dev/null +++ b/packages/client/src/components/notes.vue @@ -0,0 +1,130 @@ +<template> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <div class="_fullinfo" v-else-if="empty"> + <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;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> + + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes"> + <XNote class="qtqtichx" :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> + </XList> + + <div v-show="more && !reversed" style="margin-top: var(--margin);"> + <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> + </div> +</transition> +</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'; + +export default defineComponent({ + components: { + XNote, XList, MkButton, + }, + + mixins: [ + paging({ + before: (self) => { + self.$emit('before'); + }, + + after: (self, e) => { + self.$emit('after', e); + } + }), + ], + + props: { + pagination: { + required: true + }, + prop: { + type: String, + required: false + }, + noGap: { + type: Boolean, + required: false, + default: false + }, + }, + + 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 { + background: var(--panel); + } + } + + &:not(.noGap) { + > .notes { + background: var(--bg); + + .qtqtichx { + background: var(--panel); + border-radius: var(--radius); + } + } + } +} +</style> diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue new file mode 100644 index 0000000000..ec1efec261 --- /dev/null +++ b/packages/client/src/components/notification-setting-window.vue @@ -0,0 +1,99 @@ +<template> +<XModalWindow ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + @ok="ok()" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.notificationSetting }}</template> + <div class="_monolithic_"> + <div v-if="showGlobalToggle" class="_section"> + <MkSwitch v-model="useGlobalSetting"> + {{ $ts.useGlobalSetting }} + <template #caption>{{ $ts.useGlobalSettingDesc }}</template> + </MkSwitch> + </div> + <div v-if="!useGlobalSetting" class="_section"> + <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo> + <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> + <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkSwitch from './form/switch.vue'; +import MkInfo from './ui/info.vue'; +import MkButton from './ui/button.vue'; +import { notificationTypes } from 'misskey-js'; + +export default defineComponent({ + components: { + XModalWindow, + MkSwitch, + MkInfo, + MkButton + }, + + props: { + includingTypes: { + // TODO: これで型に合わないものを弾いてくれるのかどうか要調査 + type: Array as PropType<typeof notificationTypes[number][]>, + required: false, + default: null, + }, + showGlobalToggle: { + type: Boolean, + required: false, + default: true, + } + }, + + emits: ['done', 'closed'], + + data() { + return { + typesMap: {} as Record<typeof notificationTypes[number], boolean>, + useGlobalSetting: false, + notificationTypes, + }; + }, + + created() { + this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle; + + for (const type of this.notificationTypes) { + this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type); + } + }, + + methods: { + ok() { + const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][]) + .filter(type => this.typesMap[type]); + + this.$emit('done', { includingTypes }); + this.$refs.dialog.close(); + }, + + disableAll() { + for (const type in this.typesMap) { + this.typesMap[type as typeof notificationTypes[number]] = false; + } + }, + + enableAll() { + for (const type in this.typesMap) { + this.typesMap[type as typeof notificationTypes[number]] = true; + } + } + } +}); +</script> diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue new file mode 100644 index 0000000000..b629820043 --- /dev/null +++ b/packages/client/src/components/notification.vue @@ -0,0 +1,362 @@ +<template> +<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }" ref="elRef"> + <div class="head"> + <MkAvatar v-if="notification.user" class="icon" :user="notification.user"/> + <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> + <div class="sub-icon" :class="notification.type"> + <i v-if="notification.type === 'follow'" class="fas fa-plus"></i> + <i v-else-if="notification.type === 'receiveFollowRequest'" class="fas fa-clock"></i> + <i v-else-if="notification.type === 'followRequestAccepted'" class="fas fa-check"></i> + <i v-else-if="notification.type === 'groupInvited'" class="fas fa-id-card-alt"></i> + <i v-else-if="notification.type === 'renote'" class="fas fa-retweet"></i> + <i v-else-if="notification.type === 'reply'" class="fas fa-reply"></i> + <i v-else-if="notification.type === 'mention'" class="fas fa-at"></i> + <i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i> + <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i> + <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> + <XReactionIcon v-else-if="notification.type === 'reaction'" + :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" + :custom-emojis="notification.note.emojis" + :no-style="true" + @touchstart.passive="onReactionMouseover" + @mouseover="onReactionMouseover" + @mouseleave="onReactionMouseleave" + @touchend="onReactionMouseleave" + ref="reactionRef" + /> + </div> + </div> + <div class="tail"> + <header> + <MkA v-if="notification.user" class="name" :to="userPage(notification.user)" v-user-preview="notification.user.id"><MkUserName :user="notification.user"/></MkA> + <span v-else>{{ notification.header }}</span> + <MkTime :time="notification.createdAt" v-if="withTime" class="time"/> + </header> + <MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="fas fa-quote-left"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <i class="fas fa-quote-right"></i> + </MkA> + <MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)"> + <i class="fas fa-quote-left"></i> + <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/> + <i class="fas fa-quote-right"></i> + </MkA> + <MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="fas fa-quote-left"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <i class="fas fa-quote-right"></i> + </MkA> + <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> + <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span> + <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span> + <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $ts.reject }}</button></div></span> + <span v-if="notification.type === 'app'" class="text"> + <Mfm :text="notification.body" :nowrap="!full"/> + </span> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, onMounted, onUnmounted } from 'vue'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import XReactionIcon from './reaction-icon.vue'; +import MkFollowButton from './follow-button.vue'; +import XReactionTooltip from './reaction-tooltip.vue'; +import notePage from '@/filters/note'; +import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XReactionIcon, MkFollowButton + }, + + props: { + notification: { + type: Object, + required: true, + }, + withTime: { + type: Boolean, + required: false, + default: false, + }, + full: { + type: Boolean, + required: false, + default: false, + }, + }, + + setup(props) { + const elRef = ref<HTMLElement>(null); + const reactionRef = ref(null); + + onMounted(() => { + let readObserver: IntersectionObserver = null; + let connection = null; + + if (!props.notification.isRead) { + readObserver = new IntersectionObserver((entries, observer) => { + if (!entries.some(entry => entry.isIntersecting)) return; + os.stream.send('readNotification', { + id: props.notification.id + }); + entries.map(({ target }) => observer.unobserve(target)); + }); + + readObserver.observe(elRef.value); + + connection = os.stream.useChannel('main'); + connection.on('readAllNotifications', () => readObserver.unobserve(elRef.value)); + } + + onUnmounted(() => { + if (readObserver) readObserver.unobserve(elRef.value); + if (connection) connection.dispose(); + }); + }); + + const followRequestDone = ref(false); + const groupInviteDone = ref(false); + + const acceptFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/accept', { userId: props.notification.user.id }); + }; + + const rejectFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/reject', { userId: props.notification.user.id }); + }; + + const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); + }; + + const rejectGroupInvitation = () => { + groupInviteDone.value = true; + os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); + }; + + let isReactionHovering = false; + let reactionTooltipTimeoutId; + + const onReactionMouseover = () => { + if (isReactionHovering) return; + isReactionHovering = true; + reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300); + }; + + const onReactionMouseleave = () => { + if (!isReactionHovering) return; + isReactionHovering = false; + clearTimeout(reactionTooltipTimeoutId); + closeReactionTooltip(); + }; + + let changeReactionTooltipShowingState: () => void; + + const openReactionTooltip = () => { + closeReactionTooltip(); + if (!isReactionHovering) return; + + const showing = ref(true); + os.popup(XReactionTooltip, { + showing, + reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, + emojis: props.notification.note.emojis, + source: reactionRef.value.$el, + }, {}, 'closed'); + + changeReactionTooltipShowingState = () => { + showing.value = false; + }; + }; + + const closeReactionTooltip = () => { + if (changeReactionTooltipShowingState != null) { + changeReactionTooltipShowingState(); + changeReactionTooltipShowingState = null; + } + }; + + return { + getNoteSummary: (text: string) => getNoteSummary(text, i18n.locale), + followRequestDone, + groupInviteDone, + notePage, + userPage, + acceptFollowRequest, + rejectFollowRequest, + acceptGroupInvitation, + rejectGroupInvitation, + onReactionMouseover, + onReactionMouseleave, + elRef, + reactionRef, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.qglefbjs { + position: relative; + box-sizing: border-box; + padding: 24px 32px; + font-size: 0.9em; + overflow-wrap: break-word; + display: flex; + contain: content; + + &.max-width_600px { + padding: 16px; + font-size: 0.9em; + } + + &.max-width_500px { + padding: 12px; + font-size: 0.8em; + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > .head { + position: sticky; + top: 0; + flex-shrink: 0; + width: 42px; + height: 42px; + margin-right: 8px; + + > .icon { + display: block; + width: 100%; + height: 100%; + border-radius: 6px; + } + + > .sub-icon { + position: absolute; + z-index: 1; + bottom: -2px; + right: -2px; + width: 20px; + height: 20px; + box-sizing: border-box; + border-radius: 100%; + background: var(--panel); + box-shadow: 0 0 0 3px var(--panel); + font-size: 12px; + text-align: center; + + &:empty { + display: none; + } + + > * { + color: #fff; + width: 100%; + height: 100%; + } + + &.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited { + padding: 3px; + background: #36aed2; + pointer-events: none; + } + + &.renote { + padding: 3px; + background: #36d298; + pointer-events: none; + } + + &.quote { + padding: 3px; + background: #36d298; + pointer-events: none; + } + + &.reply { + padding: 3px; + background: #007aff; + pointer-events: none; + } + + &.mention { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + + &.pollVote { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + } + } + + > .tail { + flex: 1; + min-width: 0; + + > header { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + overflow: hidden; + } + + > .time { + margin-left: auto; + font-size: 0.9em; + } + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > i { + vertical-align: super; + font-size: 50%; + opacity: 0.5; + } + + > i:first-child { + margin-right: 4px; + } + + > i:last-child { + margin-left: 4px; + } + } + } +} +</style> diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue new file mode 100644 index 0000000000..4ebb12c44b --- /dev/null +++ b/packages/client/src/components/notifications.vue @@ -0,0 +1,159 @@ +<template> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <p class="mfcuwfyp" v-else-if="empty">{{ $ts.noNotifications }}</p> + + <div v-else> + <XList class="elsfgstc" :items="items" v-slot="{ item: notification }" :no-gap="true"> + <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/> + <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> + </XList> + + <MkButton primary style="margin: var(--margin) auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> +</transition> +</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'; +import { notificationTypes } from 'misskey-js'; +import * as os from '@/os'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + XNotification, + XList, + XNote, + MkButton, + }, + + mixins: [ + paging({}), + ], + + props: { + includeTypes: { + type: Array as PropType<typeof notificationTypes[number][]>, + required: false, + default: null, + }, + unreadOnly: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + connection: null, + pagination: { + endpoint: 'i/notifications', + limit: 10, + params: () => ({ + includeTypes: this.allIncludeTypes || undefined, + unreadOnly: this.unreadOnly, + }) + }, + }; + }, + + computed: { + allIncludeTypes() { + return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x)); + } + }, + + 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(os.stream.useChannel('main')); + this.connection.on('notification', this.onNotification); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + onNotification(notification) { + const isMuted = !this.allIncludeTypes.includes(notification.type); + if (isMuted || document.visibilityState === 'visible') { + os.stream.send('readNotification', { + id: notification.id + }); + } + + if (!isMuted) { + this.prepend({ + ...notification, + isRead: document.visibilityState === 'visible' + }); + } + }, + + noteUpdated(oldValue, newValue) { + const i = this.items.findIndex(n => n.note === oldValue); + this.items[i] = { + ...this.items[i], + note: newValue + }; + }, + } +}); +</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); +} +</style> diff --git a/packages/client/src/components/number-diff.vue b/packages/client/src/components/number-diff.vue new file mode 100644 index 0000000000..9889c97ec3 --- /dev/null +++ b/packages/client/src/components/number-diff.vue @@ -0,0 +1,47 @@ +<template> +<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }"> + <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot> +</span> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import number from '@/filters/number'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: true + }, + }, + + setup(props) { + const isPlus = computed(() => props.value > 0); + const isMinus = computed(() => props.value < 0); + const isZero = computed(() => props.value === 0); + return { + isPlus, + isMinus, + isZero, + number, + }; + } +}); +</script> + +<style lang="scss" scoped> +.ceaaebcd { + &.isPlus { + color: var(--success); + } + + &.isMinus { + color: var(--error); + } + + &.isZero { + opacity: 0.5; + } +} +</style> diff --git a/packages/client/src/components/page-preview.vue b/packages/client/src/components/page-preview.vue new file mode 100644 index 0000000000..05df1dc16e --- /dev/null +++ b/packages/client/src/components/page-preview.vue @@ -0,0 +1,162 @@ +<template> +<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1"> + <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> + <article> + <header> + <h1 :title="page.title">{{ page.title }}</h1> + </header> + <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> + <footer> + <img class="icon" :src="page.user.avatarUrl"/> + <p>{{ userName(page.user) }}</p> + </footer> + </article> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { userName } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + page: { + type: Object, + required: true + }, + }, + methods: { + userName + } +}); +</script> + +<style lang="scss" scoped> +.vhpxefrj { + display: block; + + &:hover { + text-decoration: none; + color: var(--accent); + } + + > .thumbnail { + width: 100%; + height: 200px; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + color: var(--urlPreviewTitle); + } + } + + > p { + margin: 0; + color: var(--urlPreviewText); + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + @media (max-width: 700px) { + > .thumbnail { + position: relative; + width: 100%; + height: 100px; + + & + article { + left: 0; + width: 100%; + } + } + } + + @media (max-width: 550px) { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + + @media (max-width: 500px) { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + } +} + +</style> diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue new file mode 100644 index 0000000000..b6be114cd7 --- /dev/null +++ b/packages/client/src/components/page-window.vue @@ -0,0 +1,167 @@ +<template> +<XWindow ref="window" + :initial-width="500" + :initial-height="500" + :can-resize="true" + :close-button="true" + :contextmenu="contextmenu" + @closed="$emit('closed')" +> + <template #header> + <template v-if="pageInfo"> + <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageInfo.title }}</span> + </template> + </template> + <template #headerLeft> + <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> + </template> + <div class="yrolvcoq"> + <MkStickyContainer> + <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> + <component :is="component" v-bind="props" :ref="changePage"/> + </MkStickyContainer> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XWindow from '@/components/ui/window.vue'; +import { popout } from '@/scripts/popout'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XWindow, + }, + + inject: { + sideViewHook: { + default: null + } + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + }, + shouldHeaderThin: true, + }; + }, + + props: { + initialPath: { + type: String, + required: true, + }, + initialComponent: { + type: Object, + required: true, + }, + initialProps: { + type: Object, + required: false, + default: () => {}, + }, + }, + + emits: ['closed'], + + data() { + return { + pageInfo: null, + path: this.initialPath, + component: this.initialComponent, + props: this.initialProps, + history: [], + }; + }, + + computed: { + url(): string { + return url + this.path; + }, + + contextmenu() { + return [{ + type: 'label', + text: this.path, + }, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: this.expand + }, this.sideViewHook ? { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.sideViewHook(this.path); + this.$refs.window.close(); + } + } : undefined, { + icon: 'fas fa-external-link-alt', + text: this.$ts.popout, + action: this.popout + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.$refs.window.close(); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }]; + }, + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + navigate(path, record = true) { + if (record) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + close() { + this.$refs.window.close(); + }, + + expand() { + this.$router.push(this.path); + this.$refs.window.close(); + }, + + popout() { + popout(this.path, this.$el); + this.$refs.window.close(); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.yrolvcoq { + min-height: 100%; +} +</style> diff --git a/packages/client/src/components/page/page.block.vue b/packages/client/src/components/page/page.block.vue new file mode 100644 index 0000000000..54b8b30276 --- /dev/null +++ b/packages/client/src/components/page/page.block.vue @@ -0,0 +1,44 @@ +<template> +<component :is="'x-' + block.type" :block="block" :hpml="hpml" :key="block.id" :h="h"/> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import XText from './page.text.vue'; +import XSection from './page.section.vue'; +import XImage from './page.image.vue'; +import XButton from './page.button.vue'; +import XNumberInput from './page.number-input.vue'; +import XTextInput from './page.text-input.vue'; +import XTextareaInput from './page.textarea-input.vue'; +import XSwitch from './page.switch.vue'; +import XIf from './page.if.vue'; +import XTextarea from './page.textarea.vue'; +import XPost from './page.post.vue'; +import XCounter from './page.counter.vue'; +import XRadioButton from './page.radio-button.vue'; +import XCanvas from './page.canvas.vue'; +import XNote from './page.note.vue'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { Block } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote + }, + props: { + block: { + type: Object as PropType<Block>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + }, + h: { + type: Number, + required: true + } + }, +}); +</script> diff --git a/packages/client/src/components/page/page.button.vue b/packages/client/src/components/page/page.button.vue new file mode 100644 index 0000000000..51da84bd49 --- /dev/null +++ b/packages/client/src/components/page/page.button.vue @@ -0,0 +1,66 @@ +<template> +<div> + <MkButton class="kudkigyw" @click="click()" :primary="block.primary">{{ hpml.interpolate(block.text) }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType, unref } from 'vue'; +import MkButton from '../ui/button.vue'; +import * as os from '@/os'; +import { ButtonBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkButton + }, + props: { + block: { + type: Object as PropType<ButtonBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + methods: { + click() { + if (this.block.action === 'dialog') { + this.hpml.eval(); + os.dialog({ + text: this.hpml.interpolate(this.block.content) + }); + } else if (this.block.action === 'resetRandom') { + this.hpml.updateRandomSeed(Math.random()); + this.hpml.eval(); + } else if (this.block.action === 'pushEvent') { + os.api('page-push', { + pageId: this.hpml.page.id, + event: this.block.event, + ...(this.block.var ? { + var: unref(this.hpml.vars)[this.block.var] + } : {}) + }); + + os.dialog({ + type: 'success', + text: this.hpml.interpolate(this.block.message) + }); + } else if (this.block.action === 'callAiScript') { + this.hpml.callAiScript(this.block.fn); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 200px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.canvas.vue b/packages/client/src/components/page/page.canvas.vue new file mode 100644 index 0000000000..8f49b88e5e --- /dev/null +++ b/packages/client/src/components/page/page.canvas.vue @@ -0,0 +1,49 @@ +<template> +<div class="ysrxegms"> + <canvas ref="canvas" :width="block.width" :height="block.height"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; +import * as os from '@/os'; +import { CanvasBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + props: { + block: { + type: Object as PropType<CanvasBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const canvas: Ref<any> = ref(null); + + onMounted(() => { + props.hpml.registerCanvas(props.block.name, canvas.value); + }); + + return { + canvas + }; + } +}); +</script> + +<style lang="scss" scoped> +.ysrxegms { + display: inline-block; + vertical-align: bottom; + overflow: auto; + max-width: 100%; + + > canvas { + display: block; + } +} +</style> diff --git a/packages/client/src/components/page/page.counter.vue b/packages/client/src/components/page/page.counter.vue new file mode 100644 index 0000000000..b1af8954b0 --- /dev/null +++ b/packages/client/src/components/page/page.counter.vue @@ -0,0 +1,52 @@ +<template> +<div> + <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkButton from '../ui/button.vue'; +import * as os from '@/os'; +import { CounterVarBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkButton + }, + props: { + block: { + type: Object as PropType<CounterVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function click() { + props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1)); + props.hpml.eval(); + } + + return { + click + }; + } +}); +</script> + +<style lang="scss" scoped> +.llumlmnx { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.if.vue b/packages/client/src/components/page/page.if.vue new file mode 100644 index 0000000000..ec25332db0 --- /dev/null +++ b/packages/client/src/components/page/page.if.vue @@ -0,0 +1,31 @@ +<template> +<div v-show="hpml.vars.value[block.var]"> + <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h"/> +</div> +</template> + +<script lang="ts"> +import { IfBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineComponent, defineAsyncComponent, PropType } from 'vue'; + +export default defineComponent({ + components: { + XBlock: defineAsyncComponent(() => import('./page.block.vue')) + }, + props: { + block: { + type: Object as PropType<IfBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + }, + h: { + type: Number, + required: true + } + }, +}); +</script> diff --git a/packages/client/src/components/page/page.image.vue b/packages/client/src/components/page/page.image.vue new file mode 100644 index 0000000000..04ce74bd7c --- /dev/null +++ b/packages/client/src/components/page/page.image.vue @@ -0,0 +1,40 @@ +<template> +<div class="lzyxtsnt"> + <img v-if="image" :src="image.url"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import * as os from '@/os'; +import { ImageBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + props: { + block: { + type: Object as PropType<ImageBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId); + + return { + image + }; + } +}); +</script> + +<style lang="scss" scoped> +.lzyxtsnt { + > img { + max-width: 100%; + } +} +</style> diff --git a/packages/client/src/components/page/page.note.vue b/packages/client/src/components/page/page.note.vue new file mode 100644 index 0000000000..925844c1bd --- /dev/null +++ b/packages/client/src/components/page/page.note.vue @@ -0,0 +1,47 @@ +<template> +<div class="voxdxuby"> + <XNote v-if="note && !block.detailed" v-model:note="note" :key="note.id + ':normal'"/> + <XNoteDetailed v-if="note && block.detailed" v-model:note="note" :key="note.id + ':detail'"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; +import XNote from '@/components/note.vue'; +import XNoteDetailed from '@/components/note-detailed.vue'; +import * as os from '@/os'; +import { NoteBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + XNote, + XNoteDetailed, + }, + props: { + block: { + type: Object as PropType<NoteBlock>, + required: true + } + }, + setup(props, ctx) { + const note: Ref<Record<string, any> | null> = ref(null); + + onMounted(() => { + os.api('notes/show', { noteId: props.block.note }) + .then(result => { + note.value = result; + }); + }); + + return { + note + }; + } +}); +</script> + +<style lang="scss" scoped> +.voxdxuby { + margin: 1em 0; +} +</style> diff --git a/packages/client/src/components/page/page.number-input.vue b/packages/client/src/components/page/page.number-input.vue new file mode 100644 index 0000000000..b5120d0f85 --- /dev/null +++ b/packages/client/src/components/page/page.number-input.vue @@ -0,0 +1,55 @@ +<template> +<div> + <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="number"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkInput> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkInput from '../form/input.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { NumberInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkInput + }, + props: { + block: { + type: Object as PropType<NumberInputVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.post.vue b/packages/client/src/components/page/page.post.vue new file mode 100644 index 0000000000..1b86ea1ab9 --- /dev/null +++ b/packages/client/src/components/page/page.post.vue @@ -0,0 +1,109 @@ +<template> +<div class="ngbfujlo"> + <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea> + <MkButton class="button" primary @click="post()" :disabled="posting || posted"> + <i v-if="posted" class="fas fa-check"></i> + <i v-else class="fas fa-paper-plane"></i> + </MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; +import MkButton from '../ui/button.vue'; +import { apiUrl } from '@/config'; +import * as os from '@/os'; +import { PostBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkTextarea, + MkButton, + }, + props: { + block: { + type: Object as PropType<PostBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + posted: false, + posting: false, + }; + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true + } + }, + methods: { + upload() { + const promise = new Promise((ok) => { + const canvas = this.hpml.canvases[this.block.canvasId]; + canvas.toBlob(blob => { + const data = new FormData(); + data.append('file', blob); + data.append('i', this.$i.token); + if (this.$store.state.uploadFolder) { + data.append('folderId', this.$store.state.uploadFolder); + } + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + ok(f); + }) + }); + }); + os.promiseDialog(promise); + return promise; + }, + async post() { + this.posting = true; + const file = this.block.attachCanvasImage ? await this.upload() : null; + os.apiWithDialog('notes/create', { + text: this.text === '' ? null : this.text, + fileIds: file ? [file.id] : undefined, + }).then(() => { + this.posted = true; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.ngbfujlo { + position: relative; + padding: 32px; + border-radius: 6px; + box-shadow: 0 2px 8px var(--shadow); + z-index: 1; + + > .button { + margin-top: 32px; + } + + @media (max-width: 600px) { + padding: 16px; + + > .button { + margin-top: 16px; + } + } +} +</style> diff --git a/packages/client/src/components/page/page.radio-button.vue b/packages/client/src/components/page/page.radio-button.vue new file mode 100644 index 0000000000..4d3c03291e --- /dev/null +++ b/packages/client/src/components/page/page.radio-button.vue @@ -0,0 +1,45 @@ +<template> +<div> + <div>{{ hpml.interpolate(block.title) }}</div> + <MkRadio v-for="item in block.values" :modelValue="value" @update:modelValue="updateValue($event)" :value="item" :key="item">{{ item }}</MkRadio> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkRadio from '../form/radio.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { RadioButtonVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + block: { + type: Object as PropType<RadioButtonVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue: string) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> diff --git a/packages/client/src/components/page/page.section.vue b/packages/client/src/components/page/page.section.vue new file mode 100644 index 0000000000..d32f5dc732 --- /dev/null +++ b/packages/client/src/components/page/page.section.vue @@ -0,0 +1,60 @@ +<template> +<section class="sdgxphyu"> + <component :is="'h' + h">{{ block.title }}</component> + + <div class="children"> + <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h + 1"/> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, PropType } from 'vue'; +import * as os from '@/os'; +import { SectionBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + XBlock: defineAsyncComponent(() => import('./page.block.vue')) + }, + props: { + block: { + type: Object as PropType<SectionBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + }, + h: { + required: true + } + }, +}); +</script> + +<style lang="scss" scoped> +.sdgxphyu { + margin: 1.5em 0; + + > h2 { + font-size: 1.35em; + margin: 0 0 0.5em 0; + } + + > h3 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > h4 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > .children { + //padding 16px + } +} +</style> diff --git a/packages/client/src/components/page/page.switch.vue b/packages/client/src/components/page/page.switch.vue new file mode 100644 index 0000000000..1ece88157f --- /dev/null +++ b/packages/client/src/components/page/page.switch.vue @@ -0,0 +1,55 @@ +<template> +<div class="hkcxmtwj"> + <MkSwitch :model-value="value" @update:modelValue="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkSwitch from '../form/switch.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { SwitchVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkSwitch + }, + props: { + block: { + type: Object as PropType<SwitchVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue: boolean) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> + +<style lang="scss" scoped> +.hkcxmtwj { + display: inline-block; + margin: 16px auto; + + & + .hkcxmtwj { + margin-left: 16px; + } +} +</style> diff --git a/packages/client/src/components/page/page.text-input.vue b/packages/client/src/components/page/page.text-input.vue new file mode 100644 index 0000000000..e4d3f6039a --- /dev/null +++ b/packages/client/src/components/page/page.text-input.vue @@ -0,0 +1,55 @@ +<template> +<div> + <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="text"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkInput> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkInput from '../form/input.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { TextInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkInput + }, + props: { + block: { + type: Object as PropType<TextInputVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.text.vue b/packages/client/src/components/page/page.text.vue new file mode 100644 index 0000000000..7dd41ed869 --- /dev/null +++ b/packages/client/src/components/page/page.text.vue @@ -0,0 +1,68 @@ +<template> +<div class="mrdgzndn"> + <Mfm :text="text" :is-note="false" :i="$i" :key="text"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" class="url"/> +</div> +</template> + +<script lang="ts"> +import { TextBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineAsyncComponent, defineComponent, PropType } from 'vue'; +import * as mfm from 'mfm-js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + }, + props: { + block: { + type: Object as PropType<TextBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + }; + }, + computed: { + urls(): string[] { + if (this.text) { + return extractUrlFromMfm(mfm.parse(this.text)); + } else { + return []; + } + } + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true + } + }, +}); +</script> + +<style lang="scss" scoped> +.mrdgzndn { + &:not(:first-child) { + margin-top: 0.5em; + } + + &:not(:last-child) { + margin-bottom: 0.5em; + } + + > .url { + margin: 0.5em 0; + } +} +</style> diff --git a/packages/client/src/components/page/page.textarea-input.vue b/packages/client/src/components/page/page.textarea-input.vue new file mode 100644 index 0000000000..6e082b2bef --- /dev/null +++ b/packages/client/src/components/page/page.textarea-input.vue @@ -0,0 +1,47 @@ +<template> +<div> + <MkTextarea :model-value="value" @update:modelValue="updateValue($event)"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkTextarea> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { HpmlTextInput } from '@/scripts/hpml'; +import { TextInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkTextarea + }, + props: { + block: { + type: Object as PropType<TextInputVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> diff --git a/packages/client/src/components/page/page.textarea.vue b/packages/client/src/components/page/page.textarea.vue new file mode 100644 index 0000000000..5b4ee2b452 --- /dev/null +++ b/packages/client/src/components/page/page.textarea.vue @@ -0,0 +1,39 @@ +<template> +<MkTextarea :model-value="text" readonly></MkTextarea> +</template> + +<script lang="ts"> +import { TextBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; + +export default defineComponent({ + components: { + MkTextarea + }, + props: { + block: { + type: Object as PropType<TextBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + }; + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true + } + } +}); +</script> diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue new file mode 100644 index 0000000000..6d1c419a40 --- /dev/null +++ b/packages/client/src/components/page/page.vue @@ -0,0 +1,86 @@ +<template> +<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }" v-if="hpml"> + <XBlock v-for="child in page.content" :block="child" :hpml="hpml" :key="child.id" :h="2"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue'; +import { parse } from '@syuilo/aiscript'; +import XBlock from './page.block.vue'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { url } from '@/config'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + XBlock + }, + props: { + page: { + type: Object as PropType<Record<string, any>>, + required: true + }, + }, + setup(props, ctx) { + + const hpml = new Hpml(props.page, { + randomSeed: Math.random(), + visitor: $i, + url: url, + enableAiScript: !defaultStore.state.disablePagesScript + }); + + onMounted(() => { + nextTick(() => { + if (props.page.script && hpml.aiscript) { + let ast; + try { + ast = parse(props.page.script); + } catch (e) { + console.error(e); + /*os.dialog({ + type: 'error', + text: 'Syntax error :(' + });*/ + return; + } + hpml.aiscript.exec(ast).then(() => { + hpml.eval(); + }).catch(e => { + console.error(e); + /*os.dialog({ + type: 'error', + text: e + });*/ + }); + } else { + hpml.eval(); + } + }); + onUnmounted(() => { + if (hpml.aiscript) hpml.aiscript.abort(); + }); + }); + + return { + hpml, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.iroscrza { + &.serif { + > div { + font-family: serif; + } + } + + &.center { + text-align: center; + } +} +</style> diff --git a/packages/client/src/components/particle.vue b/packages/client/src/components/particle.vue new file mode 100644 index 0000000000..d82705c1e8 --- /dev/null +++ b/packages/client/src/components/particle.vue @@ -0,0 +1,114 @@ +<template> +<div class="vswabwbm" :style="{ top: `${y - 64}px`, left: `${x - 64}px` }" :class="{ active }"> + <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> + <circle fill="none" cx="64" cy="64"> + <animate attributeName="r" + begin="0s" dur="0.5s" + values="4; 32" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> + <animate attributeName="stroke-width" + begin="0s" dur="0.5s" + values="16; 0" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + </circle> + <g fill="none" fill-rule="evenodd"> + <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color"> + <animate attributeName="r" + begin="0s" dur="0.8s" + :values="`${particle.size}; 0`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> + <animate attributeName="cx" + begin="0s" dur="0.8s" + :values="`${particle.xA}; ${particle.xB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + <animate attributeName="cy" + begin="0s" dur="0.8s" + :values="`${particle.yA}; ${particle.yB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + </circle> + </g> + </svg> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + x: { + type: Number, + required: true + }, + y: { + type: Number, + required: true + } + }, + emits: ['end'], + data() { + const particles = []; + const origin = 64; + const colors = ['#FF1493', '#00FFFF', '#FFE202']; + + for (let i = 0; i < 12; i++) { + const angle = Math.random() * (Math.PI * 2); + const pos = Math.random() * 16; + const velocity = 16 + (Math.random() * 48); + particles.push({ + size: 4 + (Math.random() * 8), + xA: origin + (Math.sin(angle) * pos), + yA: origin + (Math.cos(angle) * pos), + xB: origin + (Math.sin(angle) * (pos + velocity)), + yB: origin + (Math.cos(angle) * (pos + velocity)), + color: colors[Math.floor(Math.random() * colors.length)] + }); + } + + return { + particles + }; + }, + mounted() { + setTimeout(() => { + this.$emit('end'); + }, 1100); + } +}); +</script> + +<style lang="scss" scoped> +.vswabwbm { + pointer-events: none; + position: fixed; + z-index: 1000000; + width: 128px; + height: 128px; + + > svg { + > circle { + stroke: var(--accent); + } + } +} +</style> diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/poll-editor.vue new file mode 100644 index 0000000000..aa213cfe49 --- /dev/null +++ b/packages/client/src/components/poll-editor.vue @@ -0,0 +1,251 @@ +<template> +<div class="zmdxowus"> + <p class="caution" v-if="choices.length < 2"> + <i class="fas fa-exclamation-triangle"></i>{{ $ts._poll.noOnlyOneChoice }} + </p> + <ul ref="choices"> + <li v-for="(choice, i) in choices" :key="i"> + <MkInput class="input" :model-value="choice" @update:modelValue="onInput(i, $event)" :placeholder="$t('_poll.choiceN', { n: i + 1 })"> + </MkInput> + <button @click="remove(i)" class="_button"> + <i class="fas fa-times"></i> + </button> + </li> + </ul> + <MkButton class="add" v-if="choices.length < 10" @click="add">{{ $ts.add }}</MkButton> + <MkButton class="add" v-else disabled>{{ $ts._poll.noMore }}</MkButton> + <section> + <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch> + <div> + <MkSelect v-model="expiration"> + <template #label>{{ $ts._poll.expiration }}</template> + <option value="infinite">{{ $ts._poll.infinite }}</option> + <option value="at">{{ $ts._poll.at }}</option> + <option value="after">{{ $ts._poll.after }}</option> + </MkSelect> + <section v-if="expiration === 'at'"> + <MkInput v-model="atDate" type="date" class="input"> + <template #label>{{ $ts._poll.deadlineDate }}</template> + </MkInput> + <MkInput v-model="atTime" type="time" class="input"> + <template #label>{{ $ts._poll.deadlineTime }}</template> + </MkInput> + </section> + <section v-if="expiration === 'after'"> + <MkInput v-model="after" type="number" class="input"> + <template #label>{{ $ts._poll.duration }}</template> + </MkInput> + <MkSelect v-model="unit"> + <option value="second">{{ $ts._time.second }}</option> + <option value="minute">{{ $ts._time.minute }}</option> + <option value="hour">{{ $ts._time.hour }}</option> + <option value="day">{{ $ts._time.day }}</option> + </MkSelect> + </section> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { addTime } from '@/scripts/time'; +import { formatDateTimeString } from '@/scripts/format-time-string'; +import MkInput from './form/input.vue'; +import MkSelect from './form/select.vue'; +import MkSwitch from './form/switch.vue'; +import MkButton from './ui/button.vue'; + +export default defineComponent({ + components: { + MkInput, + MkSelect, + MkSwitch, + MkButton, + }, + + props: { + poll: { + type: Object, + required: true + } + }, + + emits: ['updated'], + + data() { + return { + choices: this.poll.choices, + multiple: this.poll.multiple, + expiration: 'infinite', + atDate: formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'), + atTime: '00:00', + after: 0, + unit: 'second', + }; + }, + + watch: { + choices: { + handler() { + this.$emit('updated', this.get()); + }, + deep: true + }, + multiple: { + handler() { + this.$emit('updated', this.get()); + }, + }, + expiration: { + handler() { + this.$emit('updated', this.get()); + }, + }, + atDate: { + handler() { + this.$emit('updated', this.get()); + }, + }, + after: { + handler() { + this.$emit('updated', this.get()); + }, + }, + unit: { + handler() { + this.$emit('updated', this.get()); + }, + }, + }, + + created() { + const poll = this.poll; + if (poll.expiresAt) { + this.expiration = 'at'; + this.atDate = this.atTime = poll.expiresAt; + } else if (typeof poll.expiredAfter === 'number') { + this.expiration = 'after'; + this.after = poll.expiredAfter / 1000; + } else { + this.expiration = 'infinite'; + } + }, + + methods: { + onInput(i, e) { + this.choices[i] = e; + }, + + add() { + this.choices.push(''); + this.$nextTick(() => { + // TODO + //(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + }); + }, + + remove(i) { + this.choices = this.choices.filter((_, _i) => _i != i); + }, + + get() { + const at = () => { + return new Date(`${this.atDate} ${this.atTime}`).getTime(); + }; + + const after = () => { + let base = parseInt(this.after); + switch (this.unit) { + case 'day': base *= 24; + case 'hour': base *= 60; + case 'minute': base *= 60; + case 'second': return base *= 1000; + default: return null; + } + }; + + return { + choices: this.choices, + multiple: this.multiple, + ...( + this.expiration === 'at' ? { expiresAt: at() } : + this.expiration === 'after' ? { expiredAfter: after() } : {} + ) + }; + }, + } +}); +</script> + +<style lang="scss" scoped> +.zmdxowus { + padding: 8px; + + > .caution { + margin: 0 0 8px 0; + font-size: 0.8em; + color: #f00; + + > i { + margin-right: 4px; + } + } + + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: flex; + margin: 8px 0; + padding: 0; + width: 100%; + + > .input { + flex: 1; + margin-top: 16px; + margin-bottom: 0; + } + + > button { + width: 32px; + padding: 4px 0; + } + } + } + + > .add { + margin: 8px 0 0 0; + z-index: 1; + } + + > section { + margin: 16px 0 -16px 0; + + > div { + margin: 0 8px; + + &:last-child { + flex: 1 0 auto; + + > section { + align-items: center; + display: flex; + margin: -32px 0 0; + + > &:first-child { + margin-right: 16px; + } + + > .input { + flex: 1 0 auto; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue new file mode 100644 index 0000000000..049fe3a435 --- /dev/null +++ b/packages/client/src/components/poll.vue @@ -0,0 +1,174 @@ +<template> +<div class="tivcixzd" :class="{ done: closed || isVoted }"> + <ul> + <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }"> + <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> + <span> + <template v-if="choice.isVoted"><i class="fas fa-check"></i></template> + <Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> + <span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> + </span> + </li> + </ul> + <p v-if="!readOnly"> + <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> + <span> · </span> + <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $ts._poll.vote : $ts._poll.showResult }}</a> + <span v-if="isVoted">{{ $ts._poll.voted }}</span> + <span v-else-if="closed">{{ $ts._poll.closed }}</span> + <span v-if="remaining > 0"> · {{ timer }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { sum } from '@/scripts/array'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + note: { + type: Object, + required: true + }, + readOnly: { + type: Boolean, + required: false, + default: false, + } + }, + data() { + return { + remaining: -1, + showResult: false, + }; + }, + computed: { + poll(): any { + return this.note.poll; + }, + total(): number { + return sum(this.poll.choices.map(x => x.votes)); + }, + closed(): boolean { + return !this.remaining; + }, + timer(): string { + return this.$t( + this.remaining >= 86400 ? '_poll.remainingDays' : + this.remaining >= 3600 ? '_poll.remainingHours' : + this.remaining >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { + s: Math.floor(this.remaining % 60), + m: Math.floor(this.remaining / 60) % 60, + h: Math.floor(this.remaining / 3600) % 24, + d: Math.floor(this.remaining / 86400) + }); + }, + isVoted(): boolean { + return !this.poll.multiple && this.poll.choices.some(c => c.isVoted); + } + }, + created() { + this.showResult = this.readOnly || this.isVoted; + + if (this.note.poll.expiresAt) { + const update = () => { + if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000)) + requestAnimationFrame(update); + else + this.showResult = true; + }; + + update(); + } + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.readOnly || this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; + os.api('notes/polls/vote', { + noteId: this.note.id, + choice: id + }).then(() => { + if (!this.showResult) this.showResult = !this.poll.multiple; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.tivcixzd { + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: block; + position: relative; + margin: 4px 0; + padding: 4px 8px; + border: solid 0.5px var(--divider); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + + &:hover { + background: rgba(#000, 0.05); + } + + &:active { + background: rgba(#000, 0.1); + } + + > .backdrop { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + transition: width 1s ease; + } + + > span { + position: relative; + + > i { + margin-right: 4px; + } + + > .votes { + margin-left: 4px; + } + } + } + } + + > p { + color: var(--fg); + + a { + color: inherit; + } + } + + &.done { + > ul > li { + cursor: default; + + &:hover { + background: transparent; + } + + &:active { + background: transparent; + } + } + } +} +</style> diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue new file mode 100644 index 0000000000..dff0dec21e --- /dev/null +++ b/packages/client/src/components/post-form-attaches.vue @@ -0,0 +1,193 @@ +<template> +<div class="skeikyzd" v-show="files.length != 0"> + <XDraggable class="files" v-model="_files" item-key="id" animation="150" delay="100" delay-on-touch-only="true"> + <template #item="{element}"> + <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> + <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> + <div class="sensitive" v-if="element.isSensitive"> + <i class="fas fa-exclamation-triangle icon"></i> + </div> + </div> + </template> + </XDraggable> + <p class="remain">{{ 4 - files.length }}/4</p> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import MkDriveFileThumbnail from './drive-file-thumbnail.vue' +import * as os from '@/os'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + MkDriveFileThumbnail + }, + + props: { + files: { + type: Array, + required: true + }, + detachMediaFn: { + type: Function, + required: false + } + }, + + emits: ['updated', 'detach', 'changeSensitive', 'changeName'], + + data() { + return { + menu: null as Promise<null> | null, + + }; + }, + + computed: { + _files: { + get() { + return this.files; + }, + set(value) { + this.$emit('updated', value); + } + } + }, + + methods: { + detachMedia(id) { + if (this.detachMediaFn) { + this.detachMediaFn(id); + } else { + this.$emit('detach', id); + } + }, + toggleSensitive(file) { + os.api('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive + }).then(() => { + this.$emit('changeSensitive', file, !file.isSensitive); + }); + }, + async rename(file) { + const { canceled, result } = await os.dialog({ + title: this.$ts.enterFileName, + input: { + default: file.name + }, + allowEmpty: false + }); + if (canceled) return; + os.api('drive/files/update', { + fileId: file.id, + name: result + }).then(() => { + this.$emit('changeName', file, result); + file.name = result; + }); + }, + + async describe(file) { + os.popup(import("@/components/media-caption.vue"), { + title: this.$ts.describeFile, + input: { + placeholder: this.$ts.inputNewDescription, + default: file.comment !== null ? file.comment : "", + }, + image: file + }, { + done: result => { + if (!result || result.canceled) return; + let comment = result.result; + os.api('drive/files/update', { + fileId: file.id, + comment: comment.length == 0 ? null : comment + }); + } + }, 'closed'); + }, + + showFileMenu(file, ev: MouseEvent) { + if (this.menu) return; + this.menu = os.popupMenu([{ + text: this.$ts.renameFile, + icon: 'fas fa-i-cursor', + action: () => { this.rename(file) } + }, { + text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, + icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', + action: () => { this.toggleSensitive(file) } + }, { + text: this.$ts.describeFile, + icon: 'fas fa-i-cursor', + action: () => { this.describe(file) } + }, { + text: this.$ts.attachCancel, + icon: 'fas fa-times-circle', + action: () => { this.detachMedia(file.id) } + }], ev.currentTarget || ev.target).then(() => this.menu = null); + } + } +}); +</script> + +<style lang="scss" scoped> +.skeikyzd { + padding: 8px 16px; + position: relative; + + > .files { + display: flex; + flex-wrap: wrap; + + > div { + position: relative; + width: 64px; + height: 64px; + margin-right: 4px; + border-radius: 4px; + overflow: hidden; + cursor: move; + + &:hover > .remove { + display: block; + } + + > .thumbnail { + width: 100%; + height: 100%; + z-index: 1; + color: var(--fg); + } + + > .sensitive { + display: flex; + position: absolute; + width: 64px; + height: 64px; + top: 0; + left: 0; + z-index: 2; + background: rgba(17, 17, 17, .7); + color: #fff; + + > .icon { + margin: auto; + } + } + } + } + + > .remain { + display: block; + position: absolute; + top: 8px; + right: 8px; + margin: 0; + padding: 0; + } +} +</style> diff --git a/packages/client/src/components/post-form-dialog.vue b/packages/client/src/components/post-form-dialog.vue new file mode 100644 index 0000000000..ae1cd7f01e --- /dev/null +++ b/packages/client/src/components/post-form-dialog.vue @@ -0,0 +1,19 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')" :position="'top'"> + <MkPostForm @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()" v-bind="$attrs"/> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkPostForm from '@/components/post-form.vue'; + +export default defineComponent({ + components: { + MkModal, + MkPostForm, + }, + emits: ['closed'], +}); +</script> diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue new file mode 100644 index 0000000000..ce6b7db3ee --- /dev/null +++ b/packages/client/src/components/post-form.vue @@ -0,0 +1,980 @@ +<template> +<div class="gafaadew" :class="{ modal, _popup: modal }" + v-size="{ max: [310, 500] }" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <header> + <button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button> + <div> + <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> + <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span> + <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> + <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span> + <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span> + <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> + <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> + </button> + <button class="_button preview" @click="showPreview = !showPreview" :class="{ active: showPreview }" v-tooltip="$ts.previewNoteText"><i class="fas fa-file-code"></i></button> + <button class="submit _buttonGradate" :disabled="!canPost" @click="post" data-cy-open-post-form-submit>{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> + </div> + </header> + <div class="form" :class="{ fixed }"> + <XNoteSimple class="preview" v-if="reply" :note="reply"/> + <XNoteSimple class="preview" v-if="renote" :note="renote"/> + <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ $ts.recipient }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers" :key="u.id"> + <MkAcct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button> + </div> + </div> + <MkInfo warn v-if="hasNotSpecifiedMentions" class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo> + <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> + <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" data-cy-post-form-text/> + <input v-show="withHashtags" ref="hashtags" class="hashtags" v-model="hashtags" :placeholder="$ts.hashtags" list="hashtags"> + <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> + <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> + <XNotePreview class="preview" v-if="showPreview" :text="text"/> + <footer> + <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button> + <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button> + <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button> + <button class="_button" @click="withHashtags = !withHashtags" :class="{ active: withHashtags }" v-tooltip="$ts.hashtags"><i class="fas fa-hashtag"></i></button> + <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button> + <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button> + </footer> + <datalist id="hashtags"> + <option v-for="hashtag in recentHashtags" :value="hashtag" :key="hashtag"/> + </datalist> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode/'; +import XNoteSimple from './note-simple.vue'; +import XNotePreview from './note-preview.vue'; +import * as mfm from 'mfm-js'; +import { host, url } from '@/config'; +import { erase, unique } from '@/scripts/array'; +import { extractMentions } from '@/scripts/extract-mentions'; +import * as Acct from 'misskey-js/built/acct'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { Autocomplete } from '@/scripts/autocomplete'; +import { noteVisibilities } from 'misskey-js'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { defaultStore, notePostInterruptors, postFormActions } from '@/store'; +import { isMobile } from '@/scripts/is-mobile'; +import { throttle } from 'throttle-debounce'; +import MkInfo from '@/components/ui/info.vue'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + XNoteSimple, + XNotePreview, + XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')), + XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')), + MkInfo, + }, + + inject: ['modal'], + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + channel: { + type: Object, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialVisibility: { + type: String, + required: false + }, + initialFiles: { + type: Array, + required: false + }, + initialLocalOnly: { + type: Boolean, + required: false + }, + visibleUsers: { + type: Array, + required: false, + default: () => [] + }, + initialNote: { + type: Object, + required: false + }, + share: { + type: Boolean, + required: false, + default: false + }, + fixed: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: true + }, + }, + + emits: ['posted', 'cancel', 'esc'], + + data() { + return { + posting: false, + text: '', + files: [], + poll: null, + useCw: false, + showPreview: false, + cw: null, + localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, + visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number], + autocomplete: null, + draghover: false, + quoteId: null, + hasNotSpecifiedMentions: false, + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + imeText: '', + typing: throttle(3000, () => { + if (this.channel) { + os.stream.send('typingOnChannel', { channel: this.channel.id }); + } + }), + postFormActions, + }; + }, + + computed: { + draftKey(): string { + let key = this.channel ? `channel:${this.channel.id}` : ''; + + if (this.renote) { + key += `renote:${this.renote.id}`; + } else if (this.reply) { + key += `reply:${this.reply.id}`; + } else { + key += 'note'; + } + + return key; + }, + + placeholder(): string { + if (this.renote) { + return this.$ts._postForm.quotePlaceholder; + } else if (this.reply) { + return this.$ts._postForm.replyPlaceholder; + } else if (this.channel) { + return this.$ts._postForm.channelPlaceholder; + } else { + const xs = [ + this.$ts._postForm._placeholders.a, + this.$ts._postForm._placeholders.b, + this.$ts._postForm._placeholders.c, + this.$ts._postForm._placeholders.d, + this.$ts._postForm._placeholders.e, + this.$ts._postForm._placeholders.f + ]; + return xs[Math.floor(Math.random() * xs.length)]; + } + }, + + submitText(): string { + return this.renote + ? this.$ts.quote + : this.reply + ? this.$ts.reply + : this.$ts.note; + }, + + textLength(): number { + return length((this.text + this.imeText).trim()); + }, + + canPost(): boolean { + return !this.posting && + (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) && + (this.textLength <= this.max) && + (!this.poll || this.poll.choices.length >= 2); + }, + + max(): number { + return this.$instance ? this.$instance.maxNoteTextLength : 1000; + }, + + withHashtags: defaultStore.makeGetterSetter('postFormWithHashtags'), + hashtags: defaultStore.makeGetterSetter('postFormHashtags'), + }, + + watch: { + text() { + this.checkMissingMention(); + }, + visibleUsers: { + handler() { + this.checkMissingMention(); + }, + deep: true + } + }, + + mounted() { + if (this.initialText) { + this.text = this.initialText; + } + + if (this.initialVisibility) { + this.visibility = this.initialVisibility; + } + + if (this.initialFiles) { + this.files = this.initialFiles; + } + + if (typeof this.initialLocalOnly === 'boolean') { + this.localOnly = this.initialLocalOnly; + } + + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; + } + + if (this.reply && this.reply.text != null) { + const ast = mfm.parse(this.reply.text); + + for (const x of extractMentions(ast)) { + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; + + // 自分は除外 + if (this.$i.username == x.username && x.host == null) continue; + if (this.$i.username == x.username && x.host == host) continue; + + // 重複は除外 + if (this.text.indexOf(`${mention} `) != -1) continue; + + this.text += `${mention} `; + } + } + + if (this.channel) { + this.visibility = 'public'; + this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す + } + + // 公開以外へのリプライ時は元の公開範囲を引き継ぐ + if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { + this.visibility = this.reply.visibility; + if (this.reply.visibility === 'specified') { + os.api('users/show', { + userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId) + }).then(users => { + this.visibleUsers.push(...users); + }); + + if (this.reply.userId !== this.$i.id) { + os.api('users/show', { userId: this.reply.userId }).then(user => { + this.visibleUsers.push(user); + }); + } + } + } + + if (this.specified) { + this.visibility = 'specified'; + this.visibleUsers.push(this.specified); + } + + // keep cw when reply + if (this.$store.state.keepCw && this.reply && this.reply.cw) { + this.useCw = true; + this.cw = this.reply.cw; + } + + if (this.autofocus) { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + } + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + new Autocomplete(this.$refs.cw, this, { model: 'cw' }); + new Autocomplete(this.$refs.hashtags, this, { model: 'hashtags' }); + + this.$nextTick(() => { + // 書きかけの投稿を復元 + if (!this.share && !this.mention && !this.specified) { + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; + if (draft) { + this.text = draft.data.text; + this.useCw = draft.data.useCw; + this.cw = draft.data.cw; + this.visibility = draft.data.visibility; + this.localOnly = draft.data.localOnly; + this.files = (draft.data.files || []).filter(e => e); + if (draft.data.poll) { + this.poll = draft.data.poll; + } + } + } + + // 削除して編集 + if (this.initialNote) { + const init = this.initialNote; + this.text = init.text ? init.text : ''; + this.files = init.files; + this.cw = init.cw; + this.useCw = init.cw != null; + if (init.poll) { + this.poll = { + choices: init.poll.choices.map(x => x.text), + multiple: init.poll.multiple, + expiresAt: init.poll.expiresAt, + expiredAfter: init.poll.expiredAfter, + }; + } + this.visibility = init.visibility; + this.localOnly = init.localOnly; + this.quoteId = init.renote ? init.renote.id : null; + } + + this.$nextTick(() => this.watch()); + }); + }, + + methods: { + watch() { + this.$watch('text', () => this.saveDraft()); + this.$watch('useCw', () => this.saveDraft()); + this.$watch('cw', () => this.saveDraft()); + this.$watch('poll', () => this.saveDraft()); + this.$watch('files', () => this.saveDraft(), { deep: true }); + this.$watch('visibility', () => this.saveDraft()); + this.$watch('localOnly', () => this.saveDraft()); + }, + + checkMissingMention() { + if (this.visibility === 'specified') { + const ast = mfm.parse(this.text); + + for (const x of extractMentions(ast)) { + if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) { + this.hasNotSpecifiedMentions = true; + return; + } + } + this.hasNotSpecifiedMentions = false; + } + }, + + addMissingMention() { + const ast = mfm.parse(this.text); + + for (const x of extractMentions(ast)) { + if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) { + os.api('users/show', { username: x.username, host: x.host }).then(user => { + this.visibleUsers.push(user); + }); + } + } + }, + + togglePoll() { + if (this.poll) { + this.poll = null; + } else { + this.poll = { + choices: ['', ''], + multiple: false, + expiresAt: null, + expiredAfter: null, + }; + } + }, + + addTag(tag: string) { + insertTextAtCursor(this.$refs.text, ` #${tag} `); + }, + + focus() { + (this.$refs.text as any).focus(); + }, + + chooseFileFrom(ev) { + selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { + for (const file of files) { + this.files.push(file); + } + }); + }, + + detachFile(id) { + this.files = this.files.filter(x => x.id != id); + }, + + updateFiles(files) { + this.files = files; + }, + + updateFileSensitive(file, sensitive) { + this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive; + }, + + updateFileName(file, name) { + this.files[this.files.findIndex(x => x.id === file.id)].name = name; + }, + + upload(file: File, name?: string) { + os.upload(file, this.$store.state.uploadFolder, name).then(res => { + this.files.push(res); + }); + }, + + onPollUpdate(poll) { + this.poll = poll; + this.saveDraft(); + }, + + setVisibility() { + if (this.channel) { + // TODO: information dialog + return; + } + + os.popup(import('./visibility-picker.vue'), { + currentVisibility: this.visibility, + currentLocalOnly: this.localOnly, + src: this.$refs.visibilityButton + }, { + changeVisibility: visibility => { + this.visibility = visibility; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('visibility', visibility); + } + }, + changeLocalOnly: localOnly => { + this.localOnly = localOnly; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('localOnly', localOnly); + } + } + }, 'closed'); + }, + + addVisibleUser() { + os.selectUser().then(user => { + this.visibleUsers.push(user); + }); + }, + + removeVisibleUser(user) { + this.visibleUsers = erase(user, this.visibleUsers); + }, + + clear() { + this.text = ''; + this.files = []; + this.poll = null; + this.quoteId = null; + }, + + onKeydown(e: KeyboardEvent) { + if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); + if (e.which === 27) this.$emit('esc'); + this.typing(); + }, + + onCompositionUpdate(e: CompositionEvent) { + this.imeText = e.data; + this.typing(); + }, + + onCompositionEnd(e: CompositionEvent) { + this.imeText = ''; + }, + + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { + if (item.kind == 'file') { + const file = item.getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + this.upload(file, formatted); + } + } + + const paste = e.clipboardData.getData('text'); + + if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { + e.preventDefault(); + + os.dialog({ + type: 'info', + text: this.$ts.quoteQuestion, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) { + insertTextAtCursor(this.$refs.text, paste); + return; + } + + this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + }); + } + }, + + onDragover(e) { + if (!e.dataTransfer.items[0]) return; + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDragenter(e) { + this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + for (const x of Array.from(e.dataTransfer.files)) this.upload(x); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + e.preventDefault(); + } + //#endregion + }, + + saveDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftKey] = { + updatedAt: new Date(), + data: { + text: this.text, + useCw: this.useCw, + cw: this.cw, + visibility: this.visibility, + localOnly: this.localOnly, + files: this.files, + poll: this.poll + } + }; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftKey]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + async post() { + let data = { + text: this.text == '' ? undefined : this.text, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + replyId: this.reply ? this.reply.id : undefined, + renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, + channelId: this.channel ? this.channel.id : undefined, + poll: this.poll, + cw: this.useCw ? this.cw || '' : undefined, + localOnly: this.localOnly, + visibility: this.visibility, + visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, + viaMobile: isMobile + }; + + if (this.withHashtags && this.hashtags && this.hashtags.trim() !== '') { + const hashtags = this.hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); + data.text = data.text ? `${data.text} ${hashtags}` : hashtags; + } + + // plugin + if (notePostInterruptors.length > 0) { + for (const interruptor of notePostInterruptors) { + data = await interruptor.handler(JSON.parse(JSON.stringify(data))); + } + } + + this.posting = true; + os.api('notes/create', data).then(() => { + this.clear(); + this.$nextTick(() => { + this.deleteDraft(); + this.$emit('posted'); + if (data.text && data.text != '') { + const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); + const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); + } + this.posting = false; + }); + }).catch(err => { + this.posting = false; + os.dialog({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); + }, + + cancel() { + this.$emit('cancel'); + }, + + insertMention() { + os.selectUser().then(user => { + insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' '); + }); + }, + + async insertEmoji(ev) { + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); + }, + + showActions(ev) { + os.popupMenu(postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: this.text + }, (key, value) => { + if (key === 'text') { this.text = value; } + }); + } + })), ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.gafaadew { + position: relative; + + &.modal { + width: 100%; + max-width: 520px; + } + + > header { + z-index: 1000; + height: 66px; + + > .cancel { + padding: 0; + font-size: 20px; + width: 64px; + line-height: 66px; + } + + > div { + position: absolute; + top: 0; + right: 0; + + > .text-count { + opacity: 0.7; + line-height: 66px; + } + + > .visibility { + height: 34px; + width: 34px; + margin: 0 0 0 8px; + + & + .localOnly { + margin-left: 0 !important; + } + } + + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; + } + + > .preview { + display: inline-block; + padding: 0; + margin: 0 8px 0 0; + font-size: 16px; + width: 34px; + height: 34px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + + > .submit { + margin: 16px 16px 16px 0; + padding: 0 12px; + line-height: 34px; + font-weight: bold; + vertical-align: bottom; + border-radius: 4px; + font-size: 0.9em; + + &:disabled { + opacity: 0.7; + } + + > i { + margin-left: 6px; + } + } + } + } + + > .form { + > .preview { + padding: 16px; + } + + > .with-quote { + margin: 0 0 8px 0; + color: var(--accent); + + > button { + padding: 4px 8px; + color: var(--accentAlpha04); + + &:hover { + color: var(--accentAlpha06); + } + + &:active { + color: var(--accentDarken30); + } + } + } + + > .to-specified { + padding: 6px 24px; + margin-bottom: 8px; + overflow: auto; + white-space: nowrap; + + > .visibleUsers { + display: inline; + top: -1px; + font-size: 14px; + + > button { + padding: 4px; + border-radius: 8px; + } + + > span { + margin-right: 14px; + padding: 8px 0 8px 8px; + border-radius: 8px; + background: var(--X4); + + > button { + padding: 4px 8px; + } + } + } + } + + > .hasNotSpecifiedMentions { + margin: 0 20px 16px 20px; + } + + > .cw, + > .hashtags, + > .text { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + > .cw { + z-index: 1; + padding-bottom: 8px; + border-bottom: solid 0.5px var(--divider); + } + + > .hashtags { + z-index: 1; + padding-top: 8px; + padding-bottom: 8px; + border-top: solid 0.5px var(--divider); + } + + > .text { + max-width: 100%; + min-width: 100%; + min-height: 90px; + + &.withCw { + padding-top: 8px; + } + } + + > footer { + padding: 0 16px 16px 16px; + + > button { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: 48px; + height: 48px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + } + } + + &.max-width_500px { + > header { + height: 50px; + + > .cancel { + width: 50px; + line-height: 50px; + } + + > div { + > .text-count { + line-height: 50px; + } + + > .submit { + margin: 8px; + } + } + } + + > .form { + > .to-specified { + padding: 6px 16px; + } + + > .cw, + > .hashtags, + > .text { + padding: 0 16px; + } + + > .text { + min-height: 80px; + } + + > footer { + padding: 0 8px 8px 8px; + } + } + } + + &.max-width_310px { + > .form { + > footer { + > button { + font-size: 14px; + width: 44px; + height: 44px; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue new file mode 100644 index 0000000000..7e0ed58cbd --- /dev/null +++ b/packages/client/src/components/queue-chart.vue @@ -0,0 +1,232 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +export default defineComponent({ + props: { + domain: { + type: String, + required: true, + }, + connection: { + required: true, + }, + }, + + setup(props) { + const chartEl = ref<HTMLCanvasElement>(null); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + onMounted(() => { + const chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Process', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00E396', + backgroundColor: alpha('#00E396', 0.1), + data: [] + }, { + label: 'Active', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00BCD4', + backgroundColor: alpha('#00BCD4', 0.1), + data: [] + }, { + label: 'Waiting', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#FFB300', + backgroundColor: alpha('#FFB300', 0.1), + yAxisID: 'y2', + data: [] + }, { + label: 'Delayed', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#E53935', + borderDash: [5, 5], + fill: false, + yAxisID: 'y2', + data: [] + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 8, + }, + }, + scales: { + x: { + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10 + }, + }, + y: { + min: 0, + stack: 'queue', + stackWeight: 2, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + y2: { + min: 0, + offset: true, + stack: 'queue', + stackWeight: 1, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + tooltip: { + mode: 'index', + animation: { + duration: 0, + }, + }, + }, + }, + }); + + const onStats = (stats) => { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + chartInstance.update(); + }; + + const onStatsLog = (statsLog) => { + for (const stats of [...statsLog].reverse()) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + } + chartInstance.update(); + }; + + props.connection.on('stats', onStats); + props.connection.on('statsLog', onStatsLog); + + onUnmounted(() => { + props.connection.off('stats', onStats); + props.connection.off('statsLog', onStatsLog); + }); + }); + + return { + chartEl, + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/components/reaction-icon.vue b/packages/client/src/components/reaction-icon.vue new file mode 100644 index 0000000000..c0ec955e32 --- /dev/null +++ b/packages/client/src/components/reaction-icon.vue @@ -0,0 +1,25 @@ +<template> +<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + reaction: { + type: String, + required: true + }, + customEmojis: { + required: false, + default: () => [] + }, + noStyle: { + type: Boolean, + required: false, + default: false + }, + }, +}); +</script> diff --git a/packages/client/src/components/reaction-tooltip.vue b/packages/client/src/components/reaction-tooltip.vue new file mode 100644 index 0000000000..93143cbe81 --- /dev/null +++ b/packages/client/src/components/reaction-tooltip.vue @@ -0,0 +1,51 @@ +<template> +<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340"> + <div class="beeadbfb"> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> + <div class="name">{{ reaction.replace('@.', '') }}</div> + </div> +</MkTooltip> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkTooltip from './ui/tooltip.vue'; +import XReactionIcon from './reaction-icon.vue'; + +export default defineComponent({ + components: { + MkTooltip, + XReactionIcon, + }, + props: { + reaction: { + type: String, + required: true, + }, + emojis: { + type: Array, + required: true, + }, + source: { + required: true, + } + }, + emits: ['closed'], +}) +</script> + +<style lang="scss" scoped> +.beeadbfb { + text-align: center; + + > .icon { + display: block; + width: 60px; + margin: 0 auto; + } + + > .name { + font-size: 0.9em; + } +} +</style> diff --git a/packages/client/src/components/reactions-viewer.details.vue b/packages/client/src/components/reactions-viewer.details.vue new file mode 100644 index 0000000000..7c49bd1d9c --- /dev/null +++ b/packages/client/src/components/reactions-viewer.details.vue @@ -0,0 +1,91 @@ +<template> +<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340"> + <div class="bqxuuuey"> + <div class="reaction"> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> + <div class="name">{{ reaction.replace('@.', '') }}</div> + </div> + <div class="users"> + <template v-if="users.length <= 10"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + </template> + <template v-if="10 < users.length"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + <span slot="omitted">+{{ count - 10 }}</span> + </template> + </div> + </div> +</MkTooltip> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkTooltip from './ui/tooltip.vue'; +import XReactionIcon from './reaction-icon.vue'; + +export default defineComponent({ + components: { + MkTooltip, + XReactionIcon + }, + props: { + reaction: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + count: { + type: Number, + required: true, + }, + emojis: { + type: Array, + required: true, + }, + source: { + required: true, + } + }, + emits: ['closed'], +}) +</script> + +<style lang="scss" scoped> +.bqxuuuey { + display: flex; + + > .reaction { + flex: 1; + max-width: 100px; + text-align: center; + + > .icon { + display: block; + width: 60px; + margin: 0 auto; + } + + > .name { + font-size: 0.9em; + } + } + + > .users { + flex: 1; + min-width: 0; + font-size: 0.9em; + border-left: solid 0.5px var(--divider); + padding-left: 10px; + margin-left: 10px; + } +} +</style> diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue new file mode 100644 index 0000000000..47a3bb9720 --- /dev/null +++ b/packages/client/src/components/reactions-viewer.reaction.vue @@ -0,0 +1,183 @@ +<template> +<button + class="hkzvhatu _button" + :class="{ reacted: note.myReaction == reaction, canToggle }" + @click="toggleReaction(reaction)" + v-if="count > 0" + @touchstart.passive="onMouseover" + @mouseover="onMouseover" + @mouseleave="onMouseleave" + @touchend="onMouseleave" + ref="reaction" + v-particle="canToggle" +> + <XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/> + <span>{{ count }}</span> +</button> +</template> + +<script lang="ts"> +import { defineComponent, ref } from 'vue'; +import XDetails from '@/components/reactions-viewer.details.vue'; +import XReactionIcon from '@/components/reaction-icon.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XReactionIcon + }, + props: { + reaction: { + type: String, + required: true, + }, + count: { + type: Number, + required: true, + }, + isInitial: { + type: Boolean, + required: true, + }, + note: { + type: Object, + required: true, + }, + }, + data() { + return { + close: null, + detailsTimeoutId: null, + isHovering: false + }; + }, + computed: { + canToggle(): boolean { + return !this.reaction.match(/@\w/) && this.$i; + }, + }, + watch: { + count(newCount, oldCount) { + if (oldCount < newCount) this.anime(); + if (this.close != null) this.openDetails(); + }, + }, + mounted() { + if (!this.isInitial) this.anime(); + }, + methods: { + toggleReaction() { + if (!this.canToggle) return; + + const oldReaction = this.note.myReaction; + if (oldReaction) { + os.api('notes/reactions/delete', { + noteId: this.note.id + }).then(() => { + if (oldReaction !== this.reaction) { + os.api('notes/reactions/create', { + noteId: this.note.id, + reaction: this.reaction + }); + } + }); + } else { + os.api('notes/reactions/create', { + noteId: this.note.id, + reaction: this.reaction + }); + } + }, + onMouseover() { + if (this.isHovering) return; + this.isHovering = true; + this.detailsTimeoutId = setTimeout(this.openDetails, 300); + }, + onMouseleave() { + if (!this.isHovering) return; + this.isHovering = false; + clearTimeout(this.detailsTimeoutId); + this.closeDetails(); + }, + openDetails() { + os.api('notes/reactions', { + noteId: this.note.id, + type: this.reaction, + limit: 11 + }).then((reactions: any[]) => { + const users = reactions + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map(x => x.user); + + this.closeDetails(); + if (!this.isHovering) return; + + const showing = ref(true); + os.popup(XDetails, { + showing, + reaction: this.reaction, + emojis: this.note.emojis, + users, + count: this.count, + source: this.$refs.reaction + }, {}, 'closed'); + + this.close = () => { + showing.value = false; + }; + }); + }, + closeDetails() { + if (this.close != null) { + this.close(); + this.close = null; + } + }, + anime() { + if (document.hidden) return; + + // TODO + }, + } +}); +</script> + +<style lang="scss" scoped> +.hkzvhatu { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + border-radius: 4px; + + &.canToggle { + background: rgba(0, 0, 0, 0.05); + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } + + &:not(.canToggle) { + cursor: default; + } + + &.reacted { + background: var(--accent); + + &:hover { + background: var(--accent); + } + + > span { + color: var(--fgOnAccent); + } + } + + > span { + font-size: 0.9em; + line-height: 32px; + margin: 0 0 0 4px; + } +} +</style> diff --git a/packages/client/src/components/reactions-viewer.vue b/packages/client/src/components/reactions-viewer.vue new file mode 100644 index 0000000000..94a0318734 --- /dev/null +++ b/packages/client/src/components/reactions-viewer.vue @@ -0,0 +1,48 @@ +<template> +<div class="tdflqwzn" :class="{ isMe }"> + <XReaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XReaction from './reactions-viewer.reaction.vue'; + +export default defineComponent({ + components: { + XReaction + }, + props: { + note: { + type: Object, + required: true + }, + }, + data() { + return { + initialReactions: new Set(Object.keys(this.note.reactions)) + }; + }, + computed: { + isMe(): boolean { + return this.$i && this.$i.id === this.note.userId; + }, + }, +}); +</script> + +<style lang="scss" scoped> +.tdflqwzn { + margin: 4px -2px 0 -2px; + + &:empty { + display: none; + } + + &.isMe { + > span { + cursor: default !important; + } + } +} +</style> diff --git a/packages/client/src/components/remote-caution.vue b/packages/client/src/components/remote-caution.vue new file mode 100644 index 0000000000..c496ea8f48 --- /dev/null +++ b/packages/client/src/components/remote-caution.vue @@ -0,0 +1,35 @@ +<template> +<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + href: { + type: String, + required: true + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.jmgmzlwq { + font-size: 0.8em; + padding: 16px; + background: var(--infoWarnBg); + color: var(--infoWarnFg); + + > a { + margin-left: 4px; + color: var(--accent); + } +} +</style> diff --git a/packages/client/src/components/sample.vue b/packages/client/src/components/sample.vue new file mode 100644 index 0000000000..ba6c682c44 --- /dev/null +++ b/packages/client/src/components/sample.vue @@ -0,0 +1,116 @@ +<template> +<div class="_card"> + <div class="_content"> + <MkInput v-model="text"> + <template #label>Text</template> + </MkInput> + <MkSwitch v-model="flag"> + <span>Switch is now {{ flag ? 'on' : 'off' }}</span> + </MkSwitch> + <div style="margin: 32px 0;"> + <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> + <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> + <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> + </div> + <MkButton inline>This is</MkButton> + <MkButton inline primary>the button</MkButton> + </div> + <div class="_content" style="pointer-events: none;"> + <Mfm :text="mfm"/> + </div> + <div class="_content"> + <MkButton inline primary @click="openMenu">Open menu</MkButton> + <MkButton inline primary @click="openDialog">Open dialog</MkButton> + <MkButton inline primary @click="openForm">Open form</MkButton> + <MkButton inline primary @click="openDrive">Open drive</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkRadio from '@/components/form/radio.vue'; +import * as os from '@/os'; +import * as config from '@/config'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + MkTextarea, + MkRadio, + }, + + data() { + return { + text: '', + flag: true, + radio: 'misskey', + mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.` + } + }, + + methods: { + async openDialog() { + os.dialog({ + type: 'warning', + title: 'Oh my Aichan', + text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }); + }, + + async openForm() { + os.form('Example form', { + foo: { + type: 'boolean', + default: true, + label: 'This is a boolean property' + }, + bar: { + type: 'number', + default: 300, + label: 'This is a number property' + }, + baz: { + type: 'string', + default: 'Misskey makes you happy.', + label: 'This is a string property' + }, + }); + }, + + async openDrive() { + os.selectDriveFile(); + }, + + async selectUser() { + os.selectUser(); + }, + + async openMenu(ev) { + os.popupMenu([{ + type: 'label', + text: 'Fruits' + }, { + text: 'Create some apples', + action: () => {}, + }, { + text: 'Read some oranges', + action: () => {}, + }, { + text: 'Update some melons', + action: () => {}, + }, null, { + text: 'Delete some bananas', + danger: true, + action: () => {}, + }], ev.currentTarget || ev.target); + }, + } +}); +</script> diff --git a/packages/client/src/components/signin-dialog.vue b/packages/client/src/components/signin-dialog.vue new file mode 100644 index 0000000000..2edd10f539 --- /dev/null +++ b/packages/client/src/components/signin-dialog.vue @@ -0,0 +1,42 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :height="400" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.login }}</template> + + <MkSignin :auto-set="autoSet" @login="onLogin"/> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkSignin from './signin.vue'; + +export default defineComponent({ + components: { + MkSignin, + XModalWindow, + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['done', 'closed'], + + methods: { + onLogin(res) { + this.$emit('done', res); + this.$refs.dialog.close(); + } + } +}); +</script> diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue new file mode 100644 index 0000000000..68bbd5368e --- /dev/null +++ b/packages/client/src/components/signin.vue @@ -0,0 +1,240 @@ +<template> +<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> + <div class="auth _section _formRoot"> + <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> + <div class="normal-signin" v-if="!totpLogin"> + <MkInput class="_formBlock" v-model="username" :placeholder="$ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:modelValue="onUsernameChange" data-cy-signin-username> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput class="_formBlock" v-model="password" :placeholder="$ts.password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required data-cy-signin-password> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption><button class="_textButton" @click="resetPassword" type="button">{{ $ts.forgotPassword }}</button></template> + </MkInput> + <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> + </div> + <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> + <div v-if="user && user.securityKeys" class="twofa-group tap-group"> + <p>{{ $ts.tapSecurityKey }}</p> + <MkButton @click="queryKey" v-if="!queryingKey"> + {{ $ts.retry }} + </MkButton> + </div> + <div class="or-hr" v-if="user && user.securityKeys"> + <p class="or-msg">{{ $ts.or }}</p> + </div> + <div class="twofa-group totp-group"> + <p style="margin-bottom:0;">{{ $ts.twoStepAuthentication }}</p> + <MkInput v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="fas fa-lock"></i></template> + </MkInput> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <template #label>{{ $ts.token }}</template> + <template #prefix><i class="fas fa-gavel"></i></template> + </MkInput> + <MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> + </div> + </div> + </div> + <div class="social _section"> + <a class="_borderButton _gap" v-if="meta && meta.enableTwitterIntegration" :href="`${apiUrl}/signin/twitter`"><i class="fab fa-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a> + <a class="_borderButton _gap" v-if="meta && meta.enableGithubIntegration" :href="`${apiUrl}/signin/github`"><i class="fab fa-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a> + <a class="_borderButton _gap" v-if="meta && meta.enableDiscordIntegration" :href="`${apiUrl}/signin/discord`"><i class="fab fa-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a> + </div> +</form> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import { apiUrl, host } from '@/config'; +import { byteify, hexify } from '@/scripts/2fa'; +import * as os from '@/os'; +import { login } from '@/account'; +import { showSuspendedDialog } from '../scripts/show-suspended-dialog'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + }, + + props: { + withAvatar: { + type: Boolean, + required: false, + default: true + }, + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['login'], + + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '', + apiUrl, + host: toUnicode(host), + totpLogin: false, + credential: null, + challengeData: null, + queryingKey: false, + }; + }, + + computed: { + meta() { + return this.$instance; + }, + }, + + methods: { + onUsernameChange() { + os.api('users/show', { + username: this.username + }).then(user => { + this.user = user; + }, () => { + this.user = null; + }); + }, + + onLogin(res) { + if (this.autoSet) { + return login(res.i); + } else { + return; + } + }, + + queryKey() { + this.queryingKey = true; + return navigator.credentials.get({ + publicKey: { + challenge: byteify(this.challengeData.challenge, 'base64'), + allowCredentials: this.challengeData.securityKeys.map(key => ({ + id: byteify(key.id, 'hex'), + type: 'public-key', + transports: ['usb', 'nfc', 'ble', 'internal'] + })), + timeout: 60 * 1000 + } + }).catch(() => { + this.queryingKey = false; + return Promise.reject(null); + }).then(credential => { + this.queryingKey = false; + this.signing = true; + return os.api('signin', { + username: this.username, + password: this.password, + signature: hexify(credential.response.signature), + authenticatorData: hexify(credential.response.authenticatorData), + clientDataJSON: hexify(credential.response.clientDataJSON), + credentialId: credential.id, + challengeId: this.challengeData.challengeId + }); + }).then(res => { + this.$emit('login', res); + return this.onLogin(res); + }).catch(err => { + if (err === null) return; + os.dialog({ + type: 'error', + text: this.$ts.signinFailed + }); + this.signing = false; + }); + }, + + onSubmit() { + this.signing = true; + if (!this.totpLogin && this.user && this.user.twoFactorEnabled) { + if (window.PublicKeyCredential && this.user.securityKeys) { + os.api('signin', { + username: this.username, + password: this.password + }).then(res => { + this.totpLogin = true; + this.signing = false; + this.challengeData = res; + return this.queryKey(); + }).catch(this.loginFailed); + } else { + this.totpLogin = true; + this.signing = false; + } + } else { + os.api('signin', { + username: this.username, + password: this.password, + token: this.user && this.user.twoFactorEnabled ? this.token : undefined + }).then(res => { + this.$emit('login', res); + this.onLogin(res); + }).catch(this.loginFailed); + } + }, + + loginFailed(err) { + switch (err.id) { + case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { + os.dialog({ + type: 'error', + title: this.$ts.loginFailed, + text: this.$ts.noSuchUser + }); + break; + } + case 'e03a5f46-d309-4865-9b69-56282d94e1eb': { + showSuspendedDialog(); + break; + } + default: { + os.dialog({ + type: 'error', + title: this.$ts.loginFailed, + text: JSON.stringify(err) + }); + } + } + + this.challengeData = null; + this.totpLogin = false; + this.signing = false; + }, + + resetPassword() { + os.popup(import('@/components/forgot-password.vue'), {}, { + }, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.eppvobhk { + > .auth { + > .avatar { + margin: 0 auto 0 auto; + width: 64px; + height: 64px; + background: #ddd; + background-position: center; + background-size: cover; + border-radius: 100%; + } + } +} +</style> diff --git a/packages/client/src/components/signup-dialog.vue b/packages/client/src/components/signup-dialog.vue new file mode 100644 index 0000000000..30fe3bf7d3 --- /dev/null +++ b/packages/client/src/components/signup-dialog.vue @@ -0,0 +1,50 @@ +<template> +<XModalWindow ref="dialog" + :width="366" + :height="500" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.signup }}</template> + + <div class="_monolithic_"> + <div class="_section"> + <XSignup :auto-set="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import XSignup from './signup.vue'; + +export default defineComponent({ + components: { + XSignup, + XModalWindow, + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['done', 'closed'], + + methods: { + onSignup(res) { + this.$emit('done', res); + this.$refs.dialog.close(); + }, + + onSignupEmailPending() { + this.$refs.dialog.close(); + } + } +}); +</script> diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue new file mode 100644 index 0000000000..621f30486f --- /dev/null +++ b/packages/client/src/components/signup.vue @@ -0,0 +1,268 @@ +<template> +<form class="qlvuhzng _formRoot" @submit.prevent="onSubmit" :autocomplete="Math.random()"> + <template v-if="meta"> + <MkInput class="_formBlock" v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> + <template #label>{{ $ts.invitationCode }}</template> + <template #prefix><i class="fas fa-key"></i></template> + </MkInput> + <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeUsername" data-cy-signup-username> + <template #label>{{ $ts.username }} <div class="_button _help" v-tooltip:dialog="$ts.usernameInfo"><i class="far fa-question-circle"></i></div></template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + <template #caption> + <span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> + <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> + <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> + <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> + <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span> + <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span> + <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span> + </template> + </MkInput> + <MkInput v-if="meta.emailRequiredForSignup" class="_formBlock" v-model="email" :debounce="true" type="email" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeEmail" data-cy-signup-email> + <template #label>{{ $ts.emailAddress }} <div class="_button _help" v-tooltip:dialog="$ts._signup.emailAddressInfo"><i class="far fa-question-circle"></i></div></template> + <template #prefix><i class="fas fa-envelope"></i></template> + <template #caption> + <span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> + <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> + <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span> + <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span> + <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span> + <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span> + <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</span> + <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> + <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> + </template> + </MkInput> + <MkInput class="_formBlock" v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption> + <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span> + <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span> + <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span> + </template> + </MkInput> + <MkInput class="_formBlock" v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePasswordRetype" data-cy-signup-password-retype> + <template #label>{{ $ts.password }} ({{ $ts.retype }})</template> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption> + <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span> + <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span> + </template> + </MkInput> + <label v-if="meta.tosUrl" class="_formBlock tou"> + <input type="checkbox" v-model="ToSAgreement"> + <I18n :src="$ts.agreeTo"> + <template #0> + <a :href="meta.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a> + </template> + </I18n> + </label> + <captcha v-if="meta.enableHcaptcha" class="_formBlock captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> + <captcha v-if="meta.enableRecaptcha" class="_formBlock captcha" provider="recaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> + <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton> + </template> +</form> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +const getPasswordStrength = require('syuilo-password-strength'); +import { toUnicode } from 'punycode/'; +import { host, url } from '@/config'; +import MkButton from './ui/button.vue'; +import MkInput from './form/input.vue'; +import MkSwitch from './form/switch.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + captcha: defineAsyncComponent(() => import('./captcha.vue')), + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['signup'], + + data() { + return { + host: toUnicode(host), + username: '', + password: '', + retypedPassword: '', + invitationCode: '', + email: '', + url, + usernameState: null, + emailState: null, + passwordStrength: '', + passwordRetypeState: null, + submitting: false, + ToSAgreement: false, + hCaptchaResponse: null, + reCaptchaResponse: null, + } + }, + + computed: { + meta() { + return this.$instance; + }, + + shouldDisableSubmitting(): boolean { + return this.submitting || + this.meta.tosUrl && !this.ToSAgreement || + this.meta.enableHcaptcha && !this.hCaptchaResponse || + this.meta.enableRecaptcha && !this.reCaptchaResponse || + this.passwordRetypeState == 'not-match'; + }, + + shouldShowProfileUrl(): boolean { + return (this.username != '' && + this.usernameState != 'invalid-format' && + this.usernameState != 'min-range' && + this.usernameState != 'max-range'); + } + }, + + methods: { + onChangeUsername() { + if (this.username == '') { + this.usernameState = null; + return; + } + + const err = + !this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : + this.username.length < 1 ? 'min-range' : + this.username.length > 20 ? 'max-range' : + null; + + if (err) { + this.usernameState = err; + return; + } + + this.usernameState = 'wait'; + + os.api('username/available', { + username: this.username + }).then(result => { + this.usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.usernameState = 'error'; + }); + }, + + onChangeEmail() { + if (this.email == '') { + this.emailState = null; + return; + } + + this.emailState = 'wait'; + + os.api('email-address/available', { + emailAddress: this.email + }).then(result => { + this.emailState = result.available ? 'ok' : + result.reason === 'used' ? 'unavailable:used' : + result.reason === 'format' ? 'unavailable:format' : + result.reason === 'disposable' ? 'unavailable:disposable' : + result.reason === 'mx' ? 'unavailable:mx' : + result.reason === 'smtp' ? 'unavailable:smtp' : + 'unavailable'; + }).catch(err => { + this.emailState = 'error'; + }); + }, + + onChangePassword() { + if (this.password == '') { + this.passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(this.password); + this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; + }, + + onChangePasswordRetype() { + if (this.retypedPassword == '') { + this.passwordRetypeState = null; + return; + } + + this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; + }, + + onSubmit() { + if (this.submitting) return; + this.submitting = true; + + os.api('signup', { + username: this.username, + password: this.password, + emailAddress: this.email, + invitationCode: this.invitationCode, + 'hcaptcha-response': this.hCaptchaResponse, + 'g-recaptcha-response': this.reCaptchaResponse, + }).then(() => { + if (this.meta.emailRequiredForSignup) { + os.dialog({ + type: 'success', + title: this.$ts._signup.almostThere, + text: this.$t('_signup.emailSent', { email: this.email }), + }); + this.$emit('signupEmailPending'); + } else { + os.api('signin', { + username: this.username, + password: this.password + }).then(res => { + this.$emit('signup', res); + + if (this.autoSet) { + login(res.i); + } + }); + } + }).catch(() => { + this.submitting = false; + this.$refs.hcaptcha?.reset?.(); + this.$refs.recaptcha?.reset?.(); + + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.qlvuhzng { + .captcha { + margin: 16px 0; + } + + > .tou { + display: block; + margin: 16px 0; + cursor: pointer; + } +} +</style> diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue new file mode 100644 index 0000000000..3aaf03995d --- /dev/null +++ b/packages/client/src/components/sparkle.vue @@ -0,0 +1,179 @@ +<template> +<span class="mk-sparkle"> + <span ref="content"> + <slot></slot> + </span> + <canvas ref="canvas"></canvas> +</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +const sprite = new Image(); +sprite.src = '/client-assets/sparkle-spritesheet.png'; + +export default defineComponent({ + props: { + count: { + type: Number, + required: true, + }, + speed: { + type: Number, + required: true, + }, + }, + data() { + return { + sprites: [0,6,13,20], + particles: [], + anim: null, + ctx: null, + }; + }, + methods: { + createSparkles(w, h, count) { + var holder = []; + + for (var i = 0; i < count; i++) { + + const color = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6); + + holder[i] = { + position: { + x: Math.floor(Math.random() * w), + y: Math.floor(Math.random() * h) + }, + style: this.sprites[ Math.floor(Math.random() * 4) ], + delta: { + x: Math.floor(Math.random() * 1000) - 500, + y: Math.floor(Math.random() * 1000) - 500 + }, + color: color, + opacity: Math.random(), + }; + + } + + return holder; + }, + draw(time) { + this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height); + this.ctx.beginPath(); + + const particleSize = Math.floor(this.fontSize / 2); + this.particles.forEach((particle) => { + var modulus = Math.floor(Math.random()*7); + + if (Math.floor(time) % modulus === 0) { + particle.style = this.sprites[ Math.floor(Math.random()*4) ]; + } + + this.ctx.save(); + this.ctx.globalAlpha = particle.opacity; + this.ctx.drawImage(sprite, particle.style, 0, 7, 7, particle.position.x, particle.position.y, particleSize, particleSize); + + this.ctx.globalCompositeOperation = "source-atop"; + this.ctx.globalAlpha = 0.5; + this.ctx.fillStyle = particle.color; + this.ctx.fillRect(particle.position.x, particle.position.y, particleSize, particleSize); + + this.ctx.restore(); + }); + this.ctx.stroke(); + }, + tick() { + this.anim = window.requestAnimationFrame((time) => { + if (!this.$refs.canvas) { + return; + } + this.particles.forEach((particle) => { + if (!particle) { + return; + } + var randX = Math.random() > Math.random() * 2; + var randY = Math.random() > Math.random() * 3; + + if (randX) { + particle.position.x += (particle.delta.x * this.speed) / 1500; + } + + if (!randY) { + particle.position.y -= (particle.delta.y * this.speed) / 800; + } + + if( particle.position.x > this.$refs.canvas.width ) { + particle.position.x = -7; + } else if (particle.position.x < -7) { + particle.position.x = this.$refs.canvas.width; + } + + if (particle.position.y > this.$refs.canvas.height) { + particle.position.y = -7; + particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width); + } else if (particle.position.y < -7) { + particle.position.y = this.$refs.canvas.height; + particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width); + } + + particle.opacity -= 0.005; + + if (particle.opacity <= 0) { + particle.opacity = 1; + } + }); + + this.draw(time); + + this.tick(); + }); + }, + resize() { + if (this.$refs.content) { + const contentRect = this.$refs.content.getBoundingClientRect(); + this.fontSize = parseFloat(getComputedStyle(this.$refs.content).fontSize); + const padding = this.fontSize * 0.2; + + this.$refs.canvas.width = parseInt(contentRect.width + padding); + this.$refs.canvas.height = parseInt(contentRect.height + padding); + + this.particles = this.createSparkles(this.$refs.canvas.width, this.$refs.canvas.height, this.count); + } + }, + }, + mounted() { + this.ctx = this.$refs.canvas.getContext('2d'); + + new ResizeObserver(this.resize).observe(this.$refs.content); + + this.resize(); + this.tick(); + }, + updated() { + this.resize(); + }, + destroyed() { + window.cancelAnimationFrame(this.anim); + }, +}); +</script> + +<style lang="scss" scoped> +.mk-sparkle { + position: relative; + display: inline-block; + + > span { + display: inline-block; + } + + > canvas { + position: absolute; + top: -0.1em; + left: -0.1em; + pointer-events: none; + } +} +</style> diff --git a/packages/client/src/components/sub-note-content.vue b/packages/client/src/components/sub-note-content.vue new file mode 100644 index 0000000000..3f03f021cd --- /dev/null +++ b/packages/client/src/components/sub-note-content.vue @@ -0,0 +1,62 @@ +<template> +<div class="wrmlmaau"> + <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> + <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> + <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <XMediaList :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ $ts.poll }}</summary> + <XPoll :note="note"/> + </details> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPoll from './poll.vue'; +import XMediaList from './media-list.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XPoll, + XMediaList, + }, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } +} +</style> diff --git a/packages/client/src/components/tab.vue b/packages/client/src/components/tab.vue new file mode 100644 index 0000000000..c629727358 --- /dev/null +++ b/packages/client/src/components/tab.vue @@ -0,0 +1,73 @@ +<script lang="ts"> +import { defineComponent, h, resolveDirective, withDirectives } from 'vue'; + +export default defineComponent({ + props: { + modelValue: { + required: true, + }, + }, + render() { + const options = this.$slots.default(); + + return withDirectives(h('div', { + class: 'pxhvhrfw', + }, options.map(option => withDirectives(h('button', { + class: ['_button', { active: this.modelValue === option.props.value }], + key: option.key, + disabled: this.modelValue === option.props.value, + onClick: () => { + this.$emit('update:modelValue', option.props.value); + } + }, option.children), [ + [resolveDirective('click-anime')] + ]))), [ + [resolveDirective('size'), { max: [500] }] + ]); + } +}); +</script> + +<style lang="scss"> +.pxhvhrfw { + display: flex; + font-size: 90%; + + > button { + flex: 1; + padding: 10px 8px; + border-radius: var(--radius); + + &:disabled { + opacity: 1 !important; + cursor: default; + } + + &.active { + color: var(--accent); + background: var(--accentedBg); + } + + &:not(.active):hover { + color: var(--fgHighlighted); + background: var(--panelHighlight); + } + + &:not(:first-child) { + margin-left: 8px; + } + + > .icon { + margin-right: 6px; + } + } + + &.max-width_500px { + font-size: 80%; + + > button { + padding: 11px 8px; + } + } +} +</style> diff --git a/packages/client/src/components/taskmanager.api-window.vue b/packages/client/src/components/taskmanager.api-window.vue new file mode 100644 index 0000000000..6ec4da3a59 --- /dev/null +++ b/packages/client/src/components/taskmanager.api-window.vue @@ -0,0 +1,72 @@ +<template> +<XWindow ref="window" + :initial-width="370" + :initial-height="450" + :can-resize="true" + @close="$refs.window.close()" + @closed="$emit('closed')" +> + <template #header>Req Viewer</template> + + <div class="rlkneywz"> + <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> + <option value="req">Request</option> + <option value="res">Response</option> + </MkTab> + + <code v-if="tab === 'req'" class="_monospace">{{ reqStr }}</code> + <code v-if="tab === 'res'" class="_monospace">{{ resStr }}</code> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import XWindow from '@/components/ui/window.vue'; +import MkTab from '@/components/tab.vue'; + +export default defineComponent({ + components: { + XWindow, + MkTab, + }, + + props: { + req: { + required: true, + } + }, + + emits: ['closed'], + + data() { + return { + tab: 'req', + reqStr: JSON5.stringify(this.req.req, null, '\t'), + resStr: JSON5.stringify(this.req.res, null, '\t'), + } + }, + + methods: { + } +}); +</script> + +<style lang="scss" scoped> +.rlkneywz { + display: flex; + flex-direction: column; + height: 100%; + + > code { + display: block; + flex: 1; + padding: 8px; + overflow: auto; + font-size: 0.9em; + tab-size: 2; + white-space: pre; + } +} +</style> diff --git a/packages/client/src/components/taskmanager.vue b/packages/client/src/components/taskmanager.vue new file mode 100644 index 0000000000..6efbf286e6 --- /dev/null +++ b/packages/client/src/components/taskmanager.vue @@ -0,0 +1,233 @@ +<template> +<XWindow ref="window" :initial-width="650" :initial-height="420" :can-resize="true" @closed="$emit('closed')"> + <template #header> + <i class="fas fa-terminal" style="margin-right: 0.5em;"></i>Task Manager + </template> + <div class="qljqmnzj _monospace"> + <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> + <option value="windows">Windows</option> + <option value="stream">Stream</option> + <option value="streamPool">Stream (Pool)</option> + <option value="api">API</option> + </MkTab> + + <div class="content"> + <div v-if="tab === 'windows'" class="windows" v-follow> + <div class="header"> + <div>#ID</div> + <div>Component</div> + <div>Action</div> + </div> + <div v-for="p in popups"> + <div>#{{ p.id }}</div> + <div>{{ p.component.name ? p.component.name : '<anonymous>' }}</div> + <div><button class="_textButton" @click="killPopup(p)">Kill</button></div> + </div> + </div> + <div v-if="tab === 'stream'" class="stream" v-follow> + <div class="header"> + <div>#ID</div> + <div>Ch</div> + <div>Handle</div> + <div>In</div> + <div>Out</div> + </div> + <div v-for="c in connections"> + <div>#{{ c.id }}</div> + <div>{{ c.channel }}</div> + <div v-if="c.users !== null">(shared)<span v-if="c.name">{{ ' ' + c.name }}</span></div> + <div v-else>{{ c.name ? c.name : '<anonymous>' }}</div> + <div>{{ c.in }}</div> + <div>{{ c.out }}</div> + </div> + </div> + <div v-if="tab === 'streamPool'" class="streamPool" v-follow> + <div class="header"> + <div>#ID</div> + <div>Ch</div> + <div>Users</div> + </div> + <div v-for="p in pools"> + <div>#{{ p.id }}</div> + <div>{{ p.channel }}</div> + <div>{{ p.users }}</div> + </div> + </div> + <div v-if="tab === 'api'" class="api" v-follow> + <div class="header"> + <div>#ID</div> + <div>Endpoint</div> + <div>State</div> + </div> + <div v-for="req in apiRequests" @click="showReq(req)"> + <div>#{{ req.id }}</div> + <div>{{ req.endpoint }}</div> + <div class="state" :class="req.state">{{ req.state }}</div> + </div> + </div> + </div> + + <footer> + <div><span class="label">Windows</span>{{ popups.length }}</div> + <div><span class="label">Stream</span>{{ connections.length }}</div> + <div><span class="label">Stream (Pool)</span>{{ pools.length }}</div> + </footer> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw, onBeforeUnmount, ref, shallowRef } from 'vue'; +import XWindow from '@/components/ui/window.vue'; +import MkTab from '@/components/tab.vue'; +import MkButton from '@/components/ui/button.vue'; +import follow from '@/directives/follow-append'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XWindow, + MkTab, + MkButton, + }, + + directives: { + follow + }, + + props: { + }, + + emits: ['closed'], + + setup() { + const connections = shallowRef([]); + const pools = shallowRef([]); + const refreshStreamInfo = () => { + console.log(os.stream.sharedConnectionPools, os.stream.sharedConnections, os.stream.nonSharedConnections); + const conn = os.stream.sharedConnections.map(c => ({ + id: c.id, name: c.name, channel: c.channel, users: c.pool.users, in: c.inCount, out: c.outCount, + })).concat(os.stream.nonSharedConnections.map(c => ({ + id: c.id, name: c.name, channel: c.channel, users: null, in: c.inCount, out: c.outCount, + }))); + conn.sort((a, b) => (a.id > b.id) ? 1 : -1); + connections.value = conn; + pools.value = os.stream.sharedConnectionPools; + }; + const interval = setInterval(refreshStreamInfo, 1000); + onBeforeUnmount(() => { + clearInterval(interval); + }); + + const killPopup = p => { + os.popups.value = os.popups.value.filter(x => x !== p); + }; + + const showReq = req => { + os.popup(import('./taskmanager.api-window.vue'), { + req: req + }, { + }, 'closed'); + }; + + return { + tab: ref('stream'), + popups: os.popups, + apiRequests: os.apiRequests, + connections, + pools, + killPopup, + showReq, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.qljqmnzj { + display: flex; + flex-direction: column; + height: 100%; + + > .content { + flex: 1; + overflow: auto; + + > div { + display: table; + width: 100%; + padding: 16px; + box-sizing: border-box; + + > div { + display: table-row; + + &:nth-child(even) { + //background: rgba(0, 0, 0, 0.1); + } + + &.header { + opacity: 0.7; + } + + > div { + display: table-cell; + white-space: nowrap; + + &:not(:last-child) { + padding-right: 8px; + } + } + } + + &.api { + > div { + &:not(.header) { + cursor: pointer; + + &:hover { + color: var(--accent); + } + } + + > .state { + &.pending { + color: var(--warn); + } + + &.success { + color: var(--success); + } + + &.failed { + color: var(--error); + } + } + } + } + } + } + + > footer { + display: flex; + width: 100%; + padding: 8px 16px; + box-sizing: border-box; + border-top: solid 0.5px var(--divider); + font-size: 0.9em; + + > div { + flex: 1; + + > .label { + opacity: 0.7; + margin-right: 0.5em; + + &:after { + content: ":"; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/timeline.vue new file mode 100644 index 0000000000..fa7f4e7f4d --- /dev/null +++ b/packages/client/src/components/timeline.vue @@ -0,0 +1,183 @@ +<template> +<XNotes :no-gap="!$store.state.showGapBetweenNotesInTimeline" ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import XNotes from './notes.vue'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; + +export default defineComponent({ + components: { + XNotes + }, + + provide() { + return { + inChannel: this.src === 'channel' + }; + }, + + 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'], + + 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 + }; + }, + + created() { + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + if (this.sound) { + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + } + }; + + const onUserAdded = () => { + (this.$refs.tl as any).reload(); + }; + + const onUserRemoved = () => { + (this.$refs.tl as any).reload(); + }; + + const onChangeFollowing = () => { + if (!this.$refs.tl.backed) { + this.$refs.tl.reload(); + } + }; + + let endpoint; + + if (this.src == 'antenna') { + endpoint = 'antennas/notes'; + this.query = { + antennaId: this.antenna + }; + this.connection = markRaw(os.stream.useChannel('antenna', { + antennaId: this.antenna + })); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + endpoint = 'notes/timeline'; + this.connection = markRaw(os.stream.useChannel('homeTimeline')); + this.connection.on('note', prepend); + + this.connection2 = markRaw(os.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(os.stream.useChannel('localTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'social') { + endpoint = 'notes/hybrid-timeline'; + this.connection = markRaw(os.stream.useChannel('hybridTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + endpoint = 'notes/global-timeline'; + this.connection = markRaw(os.stream.useChannel('globalTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'mentions') { + endpoint = 'notes/mentions'; + this.connection = markRaw(os.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(os.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(os.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(os.stream.useChannel('channel', { + channelId: this.channel + })); + this.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(); + }, + + methods: { + focus() { + this.$refs.tl.focus(); + }, + + timetravel(date?: Date) { + this.date = date; + this.$refs.tl.reload(); + } + } +}); +</script> diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue new file mode 100644 index 0000000000..fb0de68092 --- /dev/null +++ b/packages/client/src/components/toast.vue @@ -0,0 +1,73 @@ +<template> +<div class="mk-toast"> + <transition name="notification-slide" appear @after-leave="$emit('closed')"> + <XNotification :notification="notification" class="notification _acrylic" v-if="showing"/> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNotification from './notification.vue'; + +export default defineComponent({ + components: { + XNotification + }, + props: { + notification: { + type: Object, + required: true + } + }, + emits: ['closed'], + data() { + return { + showing: true + }; + }, + mounted() { + setTimeout(() => { + this.showing = false; + }, 6000); + } +}); +</script> + +<style lang="scss" scoped> +.notification-slide-enter-active, .notification-slide-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.notification-slide-enter-from, .notification-slide-leave-to { + opacity: 0; + transform: translateX(-250px); +} + +.mk-toast { + position: fixed; + z-index: 10000; + left: 0; + width: 250px; + top: 32px; + padding: 0 32px; + pointer-events: none; + + @media (max-width: 700px) { + top: initial; + bottom: 112px; + padding: 0 16px; + } + + @media (max-width: 500px) { + bottom: 92px; + padding: 0 8px; + } + + > .notification { + height: 100%; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: hidden; + } +} +</style> diff --git a/packages/client/src/components/token-generate-window.vue b/packages/client/src/components/token-generate-window.vue new file mode 100644 index 0000000000..bf5775d4d8 --- /dev/null +++ b/packages/client/src/components/token-generate-window.vue @@ -0,0 +1,117 @@ +<template> +<XModalWindow ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + :can-close="false" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>{{ title || $ts.generateAccessToken }}</template> + <div v-if="information" class="_section"> + <MkInfo warn>{{ information }}</MkInfo> + </div> + <div class="_section"> + <MkInput v-model="name"> + <template #label>{{ $ts.name }}</template> + </MkInput> + </div> + <div class="_section"> + <div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div> + <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> + <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { permissions } from 'misskey-js'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkInput from './form/input.vue'; +import MkTextarea from './form/textarea.vue'; +import MkSwitch from './form/switch.vue'; +import MkButton from './ui/button.vue'; +import MkInfo from './ui/info.vue'; + +export default defineComponent({ + components: { + XModalWindow, + MkInput, + MkTextarea, + MkSwitch, + MkButton, + MkInfo, + }, + + props: { + title: { + type: String, + required: false, + default: null + }, + information: { + type: String, + required: false, + default: null + }, + initialName: { + type: String, + required: false, + default: null + }, + initialPermissions: { + type: Array, + required: false, + default: null + } + }, + + emits: ['done', 'closed'], + + data() { + return { + name: this.initialName, + permissions: {}, + kinds: permissions + }; + }, + + created() { + if (this.initialPermissions) { + for (const kind of this.initialPermissions) { + this.permissions[kind] = true; + } + } else { + for (const kind of this.kinds) { + this.permissions[kind] = false; + } + } + }, + + methods: { + ok() { + this.$emit('done', { + name: this.name, + permissions: Object.keys(this.permissions).filter(p => this.permissions[p]) + }); + this.$refs.dialog.close(); + }, + + disableAll() { + for (const p in this.permissions) { + this.permissions[p] = false; + } + }, + + enableAll() { + for (const p in this.permissions) { + this.permissions[p] = true; + } + } + } +}); +</script> diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue new file mode 100644 index 0000000000..b5f4547c84 --- /dev/null +++ b/packages/client/src/components/ui/button.vue @@ -0,0 +1,262 @@ +<template> +<button v-if="!link" class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" + :type="type" + @click="$emit('click', $event)" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</button> +<MkA v-else class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" + :to="to" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + type: { + type: String, + required: false + }, + primary: { + type: Boolean, + required: false, + default: false + }, + gradate: { + type: Boolean, + required: false, + default: false + }, + rounded: { + type: Boolean, + required: false, + default: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + link: { + type: Boolean, + required: false, + default: false + }, + to: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + wait: { + type: Boolean, + required: false, + default: false + }, + danger: { + type: Boolean, + required: false, + default: false + }, + full: { + type: Boolean, + required: false, + default: false + }, + }, + emits: ['click'], + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$el.focus(); + }); + } + }, + methods: { + onMousedown(e: MouseEvent) { + function distance(p, q) { + return Math.hypot(p.x - q.x, p.y - q.y); + } + + function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { + const origin = {x: circleCenterX, y: circleCenterY}; + const dist1 = distance({x: 0, y: 0}, origin); + const dist2 = distance({x: boxW, y: 0}, origin); + const dist3 = distance({x: 0, y: boxH}, origin); + const dist4 = distance({x: boxW, y: boxH }, origin); + return Math.max(dist1, dist2, dist3, dist4) * 2; + } + + const rect = e.target.getBoundingClientRect(); + + const ripple = document.createElement('div'); + ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px'; + ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px'; + + this.$refs.ripples.appendChild(ripple); + + const circleCenterX = e.clientX - rect.left; + const circleCenterY = e.clientY - rect.top; + + const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); + + setTimeout(() => { + ripple.style.transform = 'scale(' + (scale / 2) + ')'; + }, 1); + setTimeout(() => { + ripple.style.transition = 'all 1s ease'; + ripple.style.opacity = '0'; + }, 1000); + setTimeout(() => { + if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); + }, 2000); + } + } +}); +</script> + +<style lang="scss" scoped> +.bghgjjyj { + position: relative; + z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため + display: block; + min-width: 100px; + width: max-content; + padding: 8px 14px; + text-align: center; + font-weight: normal; + font-size: 0.8em; + line-height: 22px; + box-shadow: none; + text-decoration: none; + background: var(--buttonBg); + border-radius: 4px; + overflow: clip; + box-sizing: border-box; + transition: background 0.1s ease; + + &:not(:disabled):hover { + background: var(--buttonHoverBg); + } + + &:not(:disabled):active { + background: var(--buttonHoverBg); + } + + &.full { + width: 100%; + } + + &.rounded { + border-radius: 999px; + } + + &.primary { + font-weight: bold; + color: var(--fgOnAccent) !important; + background: var(--accent); + + &:not(:disabled):hover { + background: var(--X8); + } + + &:not(:disabled):active { + background: var(--X8); + } + } + + &.gradate { + font-weight: bold; + color: var(--fgOnAccent) !important; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + + &:not(:disabled):hover { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + + &:not(:disabled):active { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + } + + &.danger { + color: #ff2a2a; + + &.primary { + color: #fff; + background: #ff2a2a; + + &:not(:disabled):hover { + background: #ff4242; + } + + &:not(:disabled):active { + background: #d42e2e; + } + } + } + + &:disabled { + opacity: 0.7; + } + + &:focus-visible { + outline: solid 2px var(--focus); + outline-offset: 2px; + } + + &.inline { + display: inline-block; + width: auto; + min-width: 100px; + } + + > .ripples { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 6px; + overflow: hidden; + + ::v-deep(div) { + position: absolute; + width: 2px; + height: 2px; + border-radius: 100%; + background: rgba(0, 0, 0, 0.1); + opacity: 1; + transform: scale(1); + transition: all 0.5s cubic-bezier(0,.5,0,1); + } + } + + &.primary > .ripples ::v-deep(div) { + background: rgba(0, 0, 0, 0.15); + } + + > .content { + position: relative; + z-index: 1; + } +} +</style> diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue new file mode 100644 index 0000000000..14673dfcd7 --- /dev/null +++ b/packages/client/src/components/ui/container.vue @@ -0,0 +1,262 @@ +<template> +<div class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }"> + <header v-if="showHeader" ref="header"> + <div class="title"><slot name="header"></slot></div> + <div class="sub"> + <slot name="func"></slot> + <button class="_button" v-if="foldable" @click="() => showBody = !showBody"> + <template v-if="showBody"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + </div> + </header> + <transition name="container-toggle" + @enter="enter" + @after-enter="afterEnter" + @leave="leave" + @after-leave="afterLeave" + > + <div v-show="showBody" class="content" :class="{ omitted }" ref="content"> + <slot></slot> + <button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + showHeader: { + type: Boolean, + required: false, + default: true + }, + thin: { + type: Boolean, + required: false, + default: false + }, + naked: { + type: Boolean, + required: false, + default: false + }, + foldable: { + type: Boolean, + required: false, + default: false + }, + expanded: { + type: Boolean, + required: false, + default: true + }, + scrollable: { + type: Boolean, + required: false, + default: false + }, + maxHeight: { + type: Number, + required: false, + default: null + }, + }, + data() { + return { + showBody: this.expanded, + omitted: null, + ignoreOmit: false, + }; + }, + mounted() { + this.$watch('showBody', showBody => { + const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; + this.$el.style.minHeight = `${headerHeight}px`; + if (showBody) { + this.$el.style.flexBasis = `auto`; + } else { + this.$el.style.flexBasis = `${headerHeight}px`; + } + }, { + immediate: true + }); + + this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); + + const calcOmit = () => { + if (this.omitted || this.ignoreOmit || this.maxHeight == null) return; + const height = this.$refs.content.offsetHeight; + this.omitted = height > this.maxHeight; + }; + + calcOmit(); + new ResizeObserver((entries, observer) => { + calcOmit(); + }).observe(this.$refs.content); + }, + methods: { + toggleContent(show: boolean) { + if (!this.foldable) return; + this.showBody = show; + }, + + enter(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = elementHeight + 'px'; + }, + afterEnter(el) { + el.style.height = null; + }, + leave(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; + }, + afterLeave(el) { + el.style.height = null; + }, + } +}); +</script> + +<style lang="scss" scoped> +.container-toggle-enter-active, .container-toggle-leave-active { + overflow-y: hidden; + transition: opacity 0.5s, height 0.5s !important; +} +.container-toggle-enter-from { + opacity: 0; +} +.container-toggle-leave-to { + opacity: 0; +} + +.ukygtjoj { + position: relative; + overflow: clip; + + &.naked { + background: transparent !important; + box-shadow: none !important; + } + + &.scrollable { + display: flex; + flex-direction: column; + + > .content { + overflow: auto; + } + } + + > header { + position: sticky; + top: var(--stickyTop, 0px); + left: 0; + color: var(--panelHeaderFg); + background: var(--panelHeaderBg); + border-bottom: solid 0.5px var(--panelHeaderDivider); + z-index: 2; + line-height: 1.4em; + + > .title { + margin: 0; + padding: 12px 16px; + + > ::v-deep(i) { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .sub { + position: absolute; + z-index: 2; + top: 0; + right: 0; + height: 100%; + + > ::v-deep(button) { + width: 42px; + height: 100%; + } + } + } + + > .content { + --stickyTop: 0px; + + &.omitted { + position: relative; + max-height: var(--maxHeight); + overflow: hidden; + + > .fade { + display: block; + position: absolute; + z-index: 10; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + } + + &.max-width_380px, &.thin { + > header { + > .title { + padding: 8px 10px; + font-size: 0.9em; + } + } + + > .content { + } + } +} + +._forceContainerFull_ .ukygtjoj { + > header { + > .title { + padding: 12px 16px !important; + } + } +} + +._forceContainerFull_.ukygtjoj { + > header { + > .title { + padding: 12px 16px !important; + } + } +} +</style> diff --git a/packages/client/src/components/ui/context-menu.vue b/packages/client/src/components/ui/context-menu.vue new file mode 100644 index 0000000000..561099cbe0 --- /dev/null +++ b/packages/client/src/components/ui/context-menu.vue @@ -0,0 +1,97 @@ +<template> +<transition :name="$store.state.animation ? 'fade' : ''" appear> + <div class="nvlagfpb" @contextmenu.prevent.stop="() => {}"> + <MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import contains from '@/scripts/contains'; +import MkMenu from './menu.vue'; + +export default defineComponent({ + components: { + MkMenu, + }, + props: { + items: { + type: Array, + required: true + }, + ev: { + required: true + }, + viaKeyboard: { + type: Boolean, + required: false + }, + }, + emits: ['closed'], + computed: { + keymap(): any { + return { + 'esc': () => this.$emit('closed'), + }; + }, + }, + mounted() { + let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 + let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 + + const width = this.$el.offsetWidth; + const height = this.$el.offsetHeight; + + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset; + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + this.$el.style.top = top + 'px'; + this.$el.style.left = left + 'px'; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', this.onMousedown); + } + }, + beforeUnmount() { + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + methods: { + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.nvlagfpb { + position: absolute; + z-index: 65535; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: left top; +} + +.fade-enter-from, .fade-leave-to { + opacity: 0; + transform: scale(0.9); +} +</style> diff --git a/packages/client/src/components/ui/folder.vue b/packages/client/src/components/ui/folder.vue new file mode 100644 index 0000000000..3997421d08 --- /dev/null +++ b/packages/client/src/components/ui/folder.vue @@ -0,0 +1,156 @@ +<template> +<div class="ssazuxis" v-size="{ max: [500] }"> + <header @click="showBody = !showBody" class="_button" :style="{ background: bg }"> + <div class="title"><slot name="header"></slot></div> + <div class="divider"></div> + <button class="_button"> + <template v-if="showBody"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + </header> + <transition name="folder-toggle" + @enter="enter" + @after-enter="afterEnter" + @leave="leave" + @after-leave="afterLeave" + > + <div v-show="showBody"> + <slot></slot> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; + +const localStoragePrefix = 'ui:folder:'; + +export default defineComponent({ + props: { + expanded: { + type: Boolean, + required: false, + default: true + }, + persistKey: { + type: String, + required: false, + default: null + }, + }, + data() { + return { + bg: null, + showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded, + }; + }, + watch: { + showBody() { + if (this.persistKey) { + localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f'); + } + } + }, + mounted() { + function getParentBg(el: Element | null): string { + if (el == null || el.tagName === 'BODY') return 'var(--bg)'; + const bg = el.style.background || el.style.backgroundColor; + if (bg) { + return bg; + } else { + return getParentBg(el.parentElement); + } + } + const rawBg = getParentBg(this.$el); + const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + bg.setAlpha(0.85); + this.bg = bg.toRgbString(); + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + }, + + enter(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = elementHeight + 'px'; + }, + afterEnter(el) { + el.style.height = null; + }, + leave(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; + }, + afterLeave(el) { + el.style.height = null; + }, + } +}); +</script> + +<style lang="scss" scoped> +.folder-toggle-enter-active, .folder-toggle-leave-active { + overflow-y: hidden; + transition: opacity 0.5s, height 0.5s !important; +} +.folder-toggle-enter-from { + opacity: 0; +} +.folder-toggle-leave-to { + opacity: 0; +} + +.ssazuxis { + position: relative; + + > header { + display: flex; + position: relative; + z-index: 10; + position: sticky; + top: var(--stickyTop, 0px); + padding: var(--x-padding); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(20px)); + + > .title { + margin: 0; + padding: 12px 16px 12px 0; + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .divider { + flex: 1; + margin: auto; + height: 1px; + background: var(--divider); + } + + > button { + padding: 12px 0 12px 16px; + } + } + + &.max-width_500px { + > header { + > .title { + padding: 8px 10px 8px 0; + } + } + } +} +</style> diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue new file mode 100644 index 0000000000..6b075cb440 --- /dev/null +++ b/packages/client/src/components/ui/hr.vue @@ -0,0 +1,16 @@ +<template> +<div class="evrzpitu"></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({}); +</script> + +<style lang="scss" scoped> +.evrzpitu + margin 16px 0 + border-bottom solid var(--lineWidth) var(--faceDivider) + +</style> diff --git a/packages/client/src/components/ui/info.vue b/packages/client/src/components/ui/info.vue new file mode 100644 index 0000000000..8f5986baf7 --- /dev/null +++ b/packages/client/src/components/ui/info.vue @@ -0,0 +1,45 @@ +<template> +<div class="fpezltsf" :class="{ warn }"> + <i v-if="warn" class="fas fa-exclamation-triangle"></i> + <i v-else class="fas fa-info-circle"></i> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + warn: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.fpezltsf { + padding: 16px; + font-size: 90%; + background: var(--infoBg); + color: var(--infoFg); + border-radius: var(--radius); + + &.warn { + background: var(--infoWarnBg); + color: var(--infoWarnFg); + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue new file mode 100644 index 0000000000..5938fb00a1 --- /dev/null +++ b/packages/client/src/components/ui/menu.vue @@ -0,0 +1,278 @@ +<template> +<div class="rrevdjwt" :class="{ center: align === 'center' }" + :style="{ width: width ? width + 'px' : null }" + ref="items" + @contextmenu.self="e => e.preventDefault()" + v-hotkey="keymap" +> + <template v-for="(item, i) in _items"> + <div v-if="item === null" class="divider"></div> + <span v-else-if="item.type === 'label'" class="label item"> + <span>{{ item.text }}</span> + </span> + <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> + <span><MkEllipsis/></span> + </span> + <MkA v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </MkA> + <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </a> + <button v-else-if="item.type === 'user'" @click="clicked(item.action, $event)" :tabindex="i" class="_button item"> + <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + <button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + </template> + <span v-if="_items.length === 0" class="none item"> + <span>{{ $ts.none }}</span> + </span> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, unref } from 'vue'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import contains from '@/scripts/contains'; + +export default defineComponent({ + props: { + items: { + type: Array, + required: true + }, + viaKeyboard: { + type: Boolean, + required: false + }, + align: { + type: String, + requried: false + }, + width: { + type: Number, + required: false + }, + }, + emits: ['close'], + data() { + return { + _items: [], + }; + }, + computed: { + keymap(): any { + return { + 'up|k|shift+tab': this.focusUp, + 'down|j|tab': this.focusDown, + 'esc': this.close, + }; + }, + }, + watch: { + items: { + handler() { + const items = ref(unref(this.items).filter(item => item !== undefined)); + + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + + if (item && item.then) { // if item is Promise + items.value[i] = { type: 'pending' }; + item.then(actualItem => { + items.value[i] = actualItem; + }); + } + } + + this._items = items; + }, + immediate: true + } + }, + mounted() { + if (this.viaKeyboard) { + this.$nextTick(() => { + focusNext(this.$refs.items.children[0], true, false); + }); + } + + if (this.contextmenuEvent) { + this.$el.style.top = this.contextmenuEvent.pageY + 'px'; + this.$el.style.left = this.contextmenuEvent.pageX + 'px'; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', this.onMousedown); + } + } + }, + beforeUnmount() { + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + methods: { + clicked(fn, ev) { + fn(ev); + this.close(); + }, + close() { + this.$emit('close'); + }, + focusUp() { + focusPrev(document.activeElement); + }, + focusDown() { + focusNext(document.activeElement); + }, + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.rrevdjwt { + padding: 8px 0; + min-width: 200px; + max-height: 90vh; + overflow: auto; + + &.center { + > .item { + text-align: center; + } + } + + > .item { + display: block; + position: relative; + padding: 8px 18px; + width: 100%; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.9em; + line-height: 20px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + margin: auto; + width: calc(100% - 16px); + height: 100%; + border-radius: 6px; + } + + > * { + position: relative; + } + + &.danger { + color: #ff2a2a; + + &:hover { + color: #fff; + + &:before { + background: #ff4242; + } + } + + &:active { + color: #fff; + + &:before { + background: #d42e2e; + } + } + } + + &.active { + color: var(--fgOnAccent); + opacity: 1; + + &:before { + background: var(--accent); + } + } + + &:not(:disabled):hover { + color: var(--accent); + text-decoration: none; + + &:before { + background: var(--accentedBg); + } + } + + &:not(:active):focus-visible { + box-shadow: 0 0 0 2px var(--focus) inset; + } + + &.label { + pointer-events: none; + font-size: 0.7em; + padding-bottom: 4px; + + > span { + opacity: 0.7; + } + } + + &.pending { + pointer-events: none; + opacity: 0.7; + } + + &.none { + pointer-events: none; + opacity: 0.7; + } + + > i { + margin-right: 5px; + width: 20px; + } + + > .avatar { + margin-right: 5px; + width: 20px; + height: 20px; + } + + > .indicator { + position: absolute; + top: 5px; + left: 13px; + color: var(--indicator); + font-size: 12px; + animation: blink 1s infinite; + } + } + + > .divider { + margin: 8px 0; + height: 1px; + background: var(--divider); + } +} +</style> diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue new file mode 100644 index 0000000000..da98192b87 --- /dev/null +++ b/packages/client/src/components/ui/modal-window.vue @@ -0,0 +1,148 @@ +<template> +<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> + <div class="ebkgoccj _window _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }"> + <div class="header"> + <button class="_button" v-if="withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button> + <span class="title"> + <slot name="header"></slot> + </span> + <button class="_button" v-if="!withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button> + <button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><i class="fas fa-check"></i></button> + </div> + <div class="body" v-if="padding"> + <div class="_section"> + <slot></slot> + </div> + </div> + <div class="body" v-else> + <slot></slot> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from './modal.vue'; + +export default defineComponent({ + components: { + MkModal + }, + props: { + withOkButton: { + type: Boolean, + required: false, + default: false + }, + okButtonDisabled: { + type: Boolean, + required: false, + default: false + }, + padding: { + type: Boolean, + required: false, + default: false + }, + width: { + type: Number, + required: false, + default: 400 + }, + height: { + type: Number, + required: false, + default: null + }, + canClose: { + type: Boolean, + required: false, + default: true, + }, + scroll: { + type: Boolean, + required: false, + default: true, + }, + }, + + emits: ['click', 'close', 'closed', 'ok'], + + data() { + return { + }; + }, + + methods: { + close() { + this.$refs.modal.close(); + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.ebkgoccj { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + + --root-margin: 24px; + + @media (max-width: 500px) { + --root-margin: 16px; + } + + > .header { + $height: 58px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + box-shadow: 0px 1px var(--divider); + + > button { + height: $height; + width: $height; + + @media (max-width: 500px) { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + line-height: $height; + padding-left: 32px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + + @media (max-width: 500px) { + line-height: $height-narrow; + padding-left: 16px; + } + } + + > button + .title { + padding-left: 0; + } + } + + > .body { + overflow: auto; + } +} +</style> diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue new file mode 100644 index 0000000000..33fcdb687f --- /dev/null +++ b/packages/client/src/components/ui/modal.vue @@ -0,0 +1,292 @@ +<template> +<transition :name="$store.state.animation ? popup ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? popup ? 500 : 300 : 0" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> + <div v-show="manualShowing != null ? manualShowing : showing" class="qzhlnise" :class="{ front }" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> + <div class="bg _modalBg" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div> + <div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content"> + <slot></slot> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +function getFixedContainer(el: Element | null): Element | null { + if (el == null || el.tagName === 'BODY') return null; + const position = window.getComputedStyle(el).getPropertyValue('position'); + if (position === 'fixed') { + return el; + } else { + return getFixedContainer(el.parentElement); + } +} + +export default defineComponent({ + provide: { + modal: true + }, + props: { + manualShowing: { + type: Boolean, + required: false, + default: null, + }, + srcCenter: { + type: Boolean, + required: false + }, + src: { + required: false, + }, + position: { + required: false + }, + front: { + type: Boolean, + required: false, + default: false, + } + }, + emits: ['opening', 'click', 'esc', 'close', 'closed'], + data() { + return { + showing: true, + fixed: false, + transformOrigin: 'center', + contentClicking: false, + }; + }, + computed: { + keymap(): any { + return { + 'esc': () => this.$emit('esc'), + }; + }, + popup(): boolean { + return this.src != null; + } + }, + mounted() { + this.$watch('src', () => { + this.fixed = getFixedContainer(this.src) != null; + this.$nextTick(() => { + this.align(); + }); + }, { immediate: true }); + + this.$nextTick(() => { + const popover = this.$refs.content as any; + new ResizeObserver((entries, observer) => { + this.align(); + }).observe(popover); + }); + }, + methods: { + align() { + if (!this.popup) return; + + const popover = this.$refs.content as any; + + if (popover == null) return; + + const rect = this.src.getBoundingClientRect(); + + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.srcCenter) { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + } else { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (this.fixed) { + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + } + + if (top + height > window.innerHeight) { + top = window.innerHeight - height; + } + } else { + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset - 1; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset - 1; + } + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { + this.transformOrigin = 'center top'; + } else { + this.transformOrigin = 'center'; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + }, + + childRendered() { + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const content = this.$refs.content.children[0]; + content.addEventListener('mousedown', e => { + this.contentClicking = true; + window.addEventListener('mouseup', e => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + setTimeout(() => { + this.contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); + }, + + close() { + this.showing = false; + this.$emit('close'); + }, + + onBgClick() { + if (this.contentClicking) return; + this.$emit('click'); + }, + + onClosed() { + this.$emit('closed'); + } + } +}); +</script> + +<style lang="scss"> +.modal-popup-enter-active, .modal-popup-leave-active, +.modal-enter-from, .modal-leave-to { + > .content { + transform-origin: var(--transformOrigin); + } +} +</style> + +<style lang="scss" scoped> +.modal-enter-active, .modal-leave-active { + > .bg { + transition: opacity 0.3s !important; + } + + > .content { + transition: opacity 0.3s, transform 0.3s !important; + } +} +.modal-enter-from, .modal-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + opacity: 0; + transform: scale(0.9); + } +} + +.modal-popup-enter-active, .modal-popup-leave-active { + > .bg { + transition: opacity 0.3s !important; + } + + > .content { + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1) !important; + } +} +.modal-popup-enter-from, .modal-popup-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + opacity: 0; + transform: scale(0.9); + } +} + +.qzhlnise { + > .bg { + z-index: 10000; + } + + > .content:not(.popup) { + position: fixed; + z-index: 10000; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + padding: 32px; + // TODO: mask-imageはiOSだとやたら重い。なんとかしたい + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + overflow: auto; + display: flex; + + @media (max-width: 500px) { + padding: 16px; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + } + + > ::v-deep(*) { + margin: auto; + } + + &.top { + > ::v-deep(*) { + margin-top: 0; + } + } + } + + > .content.popup { + position: absolute; + z-index: 10000; + + &.fixed { + position: fixed; + } + } + + &.front { + > .bg { + z-index: 20000; + } + + > .content:not(.popup) { + z-index: 20000; + } + + > .content.popup { + z-index: 20000; + } + } +} +</style> diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue new file mode 100644 index 0000000000..f6a457d88f --- /dev/null +++ b/packages/client/src/components/ui/pagination.vue @@ -0,0 +1,69 @@ +<template> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <div class="empty" v-else-if="empty" key="_empty_"> + <slot name="empty"></slot> + </div> + + <div v-else class="cxiknjgy"> + <slot :items="items"></slot> + <div class="more _gap" v-show="more" key="_more_"> + <MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from './button.vue'; +import paging from '@/scripts/paging'; + +export default defineComponent({ + components: { + MkButton + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + + disableAutoLoad: { + type: Boolean, + required: false, + default: false, + } + }, +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.cxiknjgy { + > .more > .button { + margin-left: auto; + margin-right: auto; + height: 48px; + min-width: 150px; + } +} +</style> diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/ui/popup-menu.vue new file mode 100644 index 0000000000..3ff4c658b1 --- /dev/null +++ b/packages/client/src/components/ui/popup-menu.vue @@ -0,0 +1,42 @@ +<template> +<MkPopup ref="popup" :src="src" @closed="$emit('closed')"> + <MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/> +</MkPopup> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPopup from './popup.vue'; +import MkMenu from './menu.vue'; + +export default defineComponent({ + components: { + MkPopup, + MkMenu, + }, + + props: { + items: { + type: Array, + required: true + }, + align: { + type: String, + required: false + }, + width: { + type: Number, + required: false + }, + viaKeyboard: { + type: Boolean, + required: false + }, + src: { + required: false + }, + }, + + emits: ['close', 'closed'], +}); +</script> diff --git a/packages/client/src/components/ui/popup.vue b/packages/client/src/components/ui/popup.vue new file mode 100644 index 0000000000..0fb1780cc5 --- /dev/null +++ b/packages/client/src/components/ui/popup.vue @@ -0,0 +1,213 @@ +<template> +<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> + <div v-show="manualShowing != null ? manualShowing : showing" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" ref="content" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> + <slot></slot> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; + +function getFixedContainer(el: Element | null): Element | null { + if (el == null || el.tagName === 'BODY') return null; + const position = window.getComputedStyle(el).getPropertyValue('position'); + if (position === 'fixed') { + return el; + } else { + return getFixedContainer(el.parentElement); + } +} + +export default defineComponent({ + props: { + manualShowing: { + type: Boolean, + required: false, + default: null, + }, + srcCenter: { + type: Boolean, + required: false + }, + src: { + type: Object as PropType<HTMLElement>, + required: false, + }, + position: { + required: false + }, + front: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['opening', 'click', 'esc', 'close', 'closed'], + + data() { + return { + showing: true, + fixed: false, + transformOrigin: 'center', + contentClicking: false, + }; + }, + + mounted() { + this.$watch('src', () => { + if (this.src) { + this.src.style.pointerEvents = 'none'; + } + this.fixed = getFixedContainer(this.src) != null; + this.$nextTick(() => { + this.align(); + }); + }, { immediate: true }); + + this.$nextTick(() => { + const popover = this.$refs.content as any; + new ResizeObserver((entries, observer) => { + this.align(); + }).observe(popover); + }); + + document.addEventListener('mousedown', this.onDocumentClick, { passive: true }); + }, + + beforeUnmount() { + document.removeEventListener('mousedown', this.onDocumentClick); + }, + + methods: { + align() { + if (this.src == null) return; + + const popover = this.$refs.content as any; + + if (popover == null) return; + + const rect = this.src.getBoundingClientRect(); + + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.srcCenter) { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + } else { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (this.fixed) { + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + } + + if (top + height > window.innerHeight) { + top = window.innerHeight - height; + } + } else { + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset - 1; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset - 1; + } + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { + this.transformOrigin = 'center top'; + } else { + this.transformOrigin = 'center'; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + }, + + childRendered() { + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const content = this.$refs.content.children[0]; + content.addEventListener('mousedown', e => { + this.contentClicking = true; + window.addEventListener('mouseup', e => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + setTimeout(() => { + this.contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); + }, + + close() { + if (this.src) this.src.style.pointerEvents = 'auto'; + this.showing = false; + this.$emit('close'); + }, + + onClosed() { + this.$emit('closed'); + }, + + onDocumentClick(ev) { + const flyoutElement = this.$refs.content; + let targetElement = ev.target; + do { + if (targetElement === flyoutElement) { + return; + } + targetElement = targetElement.parentNode; + } while (targetElement); + this.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.popup-menu-enter-active { + transform-origin: var(--transformOrigin); + transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important; +} +.popup-menu-leave-active { + transform-origin: var(--transformOrigin); + transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), transform 0.2s cubic-bezier(0.4, 0, 1, 1) !important; +} +.popup-menu-enter-from, .popup-menu-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.ccczpooj { + position: absolute; + z-index: 10000; + + &.fixed { + position: fixed; + } + + &.front { + z-index: 20000; + } +} +</style> diff --git a/packages/client/src/components/ui/super-menu.vue b/packages/client/src/components/ui/super-menu.vue new file mode 100644 index 0000000000..195cc57326 --- /dev/null +++ b/packages/client/src/components/ui/super-menu.vue @@ -0,0 +1,148 @@ +<template> +<div class="rrevdjwu" :class="{ grid }"> + <div class="group" v-for="group in def"> + <div class="title" v-if="group.title">{{ group.title }}</div> + + <div class="items"> + <template v-for="(item, i) in group.items"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </a> + <button v-else-if="item.type === 'button'" @click="ev => item.action(ev)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </button> + <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </MkA> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, unref } from 'vue'; + +export default defineComponent({ + props: { + def: { + type: Array, + required: true + }, + grid: { + type: Boolean, + required: false, + default: false, + }, + }, +}); +</script> + +<style lang="scss" scoped> +.rrevdjwu { + > .group { + & + .group { + margin-top: 16px; + padding-top: 16px; + border-top: solid 0.5px var(--divider); + } + + > .title { + font-size: 0.9em; + opacity: 0.7; + margin: 0 0 8px 12px; + } + + > .items { + > .item { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 16px 10px 8px; + border-radius: 9px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--panelHighlight); + } + + &.active { + color: var(--accent); + background: var(--accentedBg); + } + + &.danger { + color: var(--error); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + } + + > .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 12px; + } + + } + } + } + + &.grid { + > .group { + & + .group { + padding-top: 0; + border-top: none; + } + + margin-left: 0; + margin-right: 0; + + > .title { + font-size: 1em; + opacity: 0.7; + margin: 0 0 8px 16px; + } + + > .items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + grid-gap: 8px; + padding: 0 16px; + + > .item { + flex-direction: column; + padding: 18px 16px 16px 16px; + background: var(--panel); + border-radius: 8px; + text-align: center; + + > .icon { + display: block; + margin-right: 0; + margin-bottom: 12px; + font-size: 1.5em; + } + + > .text { + padding-right: 0; + width: 100%; + font-size: 0.8em; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue new file mode 100644 index 0000000000..c003895c14 --- /dev/null +++ b/packages/client/src/components/ui/tooltip.vue @@ -0,0 +1,92 @@ +<template> +<transition name="tooltip" appear @after-leave="$emit('closed')"> + <div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content" :style="{ maxWidth: maxWidth + 'px' }"> + <slot>{{ text }}</slot> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + showing: { + type: Boolean, + required: true, + }, + source: { + required: true, + }, + text: { + type: String, + required: false + }, + maxWidth: { + type: Number, + required: false, + default: 250, + }, + }, + + emits: ['closed'], + + mounted() { + this.$nextTick(() => { + if (this.source == null) { + this.$emit('closed'); + return; + } + + const rect = this.source.getBoundingClientRect(); + + const contentWidth = this.$refs.content.offsetWidth; + const contentHeight = this.$refs.content.offsetHeight; + + let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + let top = rect.top + window.pageYOffset - contentHeight; + + left -= (this.$el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + if (top - window.pageYOffset < 0) { + top = rect.top + window.pageYOffset + this.source.offsetHeight; + this.$refs.content.style.transformOrigin = 'center top'; + } + + this.$el.style.left = left + 'px'; + this.$el.style.top = top + 'px'; + }); + }, +}) +</script> + +<style lang="scss" scoped> +.tooltip-enter-active, +.tooltip-leave-active { + opacity: 1; + transform: scale(1); + transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tooltip-enter-from, +.tooltip-leave-active { + opacity: 0; + transform: scale(0.75); +} + +.buebdbiu { + position: absolute; + z-index: 11000; + font-size: 0.8em; + padding: 8px 12px; + box-sizing: border-box; + text-align: center; + border-radius: 4px; + border: solid 0.5px var(--divider); + pointer-events: none; + transform-origin: center bottom; +} +</style> diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue new file mode 100644 index 0000000000..b7093b6641 --- /dev/null +++ b/packages/client/src/components/ui/window.vue @@ -0,0 +1,525 @@ +<template> +<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> + <div class="ebkgocck" :class="{ front }" v-if="showing"> + <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> + <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> + <span class="left"> + <slot name="headerLeft"></slot> + </span> + <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> + <slot name="header"></slot> + </span> + <span class="right"> + <slot name="headerRight"></slot> + <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button> + </span> + </div> + <div class="body" v-if="padding"> + <div class="_section"> + <slot></slot> + </div> + </div> + <div class="body" v-else> + <slot></slot> + </div> + </div> + <template v-if="canResize"> + <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </template> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import contains from '@/scripts/contains'; +import * as os from '@/os'; + +const minHeight = 50; +const minWidth = 250; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('touchmove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); + window.addEventListener('touchend', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('touchmove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); + window.removeEventListener('touchend', dragClear); +} + +export default defineComponent({ + provide: { + inWindow: true + }, + + props: { + padding: { + type: Boolean, + required: false, + default: false + }, + initialWidth: { + type: Number, + required: false, + default: 400 + }, + initialHeight: { + type: Number, + required: false, + default: null + }, + canResize: { + type: Boolean, + required: false, + default: false, + }, + closeButton: { + type: Boolean, + required: false, + default: true, + }, + mini: { + type: Boolean, + required: false, + default: false, + }, + front: { + type: Boolean, + required: false, + default: false, + }, + contextmenu: { + type: Array, + required: false, + } + }, + + emits: ['closed'], + + data() { + return { + showing: true, + id: Math.random().toString(), // TODO: UUIDとかにする + }; + }, + + mounted() { + if (this.initialWidth) this.applyTransformWidth(this.initialWidth); + if (this.initialHeight) this.applyTransformHeight(this.initialHeight); + + this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2)); + this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2)); + + os.windows.set(this.id, { + z: Number(document.defaultView.getComputedStyle(this.$el, null).zIndex) + }); + + // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする + this.top(); + + window.addEventListener('resize', this.onBrowserResize); + }, + + unmounted() { + os.windows.delete(this.id); + window.removeEventListener('resize', this.onBrowserResize); + }, + + methods: { + close() { + this.showing = false; + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + + onContextmenu(e) { + if (this.contextmenu) { + os.contextMenu(this.contextmenu, e); + } + }, + + // 最前面へ移動 + top() { + let z = 0; + const ws = Array.from(os.windows.entries()).filter(([k, v]) => k !== this.id).map(([k, v]) => v); + for (const w of ws) { + if (w.z > z) z = w.z; + } + if (z > 0) { + (this.$el as any).style.zIndex = z + 1; + os.windows.set(this.id, { + z: z + 1 + }); + } + }, + + onBodyMousedown() { + this.top(); + }, + + onHeaderMousedown(e) { + const main = this.$el as any; + + if (!contains(main, document.activeElement)) main.focus(); + + const position = main.getBoundingClientRect(); + + const clickX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX; + const clickY = e.touches && e.touches.length > 0 ? e.touches[0].clientY : e.clientY; + const moveBaseX = clickX - position.left; + const moveBaseY = clickY - position.top; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + // 動かした時 + dragListen(me => { + const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; + const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; + + let moveLeft = x - moveBaseX; + let moveTop = y - moveBaseY; + + // 下はみ出し + if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; + + // 左はみ出し + if (moveLeft < 0) moveLeft = 0; + + // 上はみ出し + if (moveTop < 0) moveTop = 0; + + // 右はみ出し + if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; + + this.$el.style.left = moveLeft + 'px'; + this.$el.style.top = moveTop + 'px'; + }); + }, + + // 上ハンドル掴み時 + onTopHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + move > 0) { + if (height + -move > minHeight) { + this.applyTransformHeight(height + -move); + this.applyTransformTop(top + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + this.applyTransformTop(top + (height - minHeight)); + } + } else { // 上のはみ出し時 + this.applyTransformHeight(top + height); + this.applyTransformTop(0); + } + }); + }, + + // 右ハンドル掴み時 + onRightHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + const browserWidth = window.innerWidth; + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + width + move < browserWidth) { + if (width + move > minWidth) { + this.applyTransformWidth(width + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + } + } else { // 右のはみ出し時 + this.applyTransformWidth(browserWidth - left); + } + }); + }, + + // 下ハンドル掴み時 + onBottomHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + const browserHeight = window.innerHeight; + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + height + move < browserHeight) { + if (height + move > minHeight) { + this.applyTransformHeight(height + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + } + } else { // 下のはみ出し時 + this.applyTransformHeight(browserHeight - top); + } + }); + }, + + // 左ハンドル掴み時 + onLeftHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + move > 0) { + if (width + -move > minWidth) { + this.applyTransformWidth(width + -move); + this.applyTransformLeft(left + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + this.applyTransformLeft(left + (width - minWidth)); + } + } else { // 左のはみ出し時 + this.applyTransformWidth(left + width); + this.applyTransformLeft(0); + } + }); + }, + + // 左上ハンドル掴み時 + onTopLeftHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 右上ハンドル掴み時 + onTopRightHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 右下ハンドル掴み時 + onBottomRightHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 左下ハンドル掴み時 + onBottomLeftHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 高さを適用 + applyTransformHeight(height) { + if (height > window.innerHeight) height = window.innerHeight; + (this.$el as any).style.height = height + 'px'; + }, + + // 幅を適用 + applyTransformWidth(width) { + if (width > window.innerWidth) width = window.innerWidth; + (this.$el as any).style.width = width + 'px'; + }, + + // Y座標を適用 + applyTransformTop(top) { + (this.$el as any).style.top = top + 'px'; + }, + + // X座標を適用 + applyTransformLeft(left) { + (this.$el as any).style.left = left + 'px'; + }, + + onBrowserResize() { + const main = this.$el as any; + const position = main.getBoundingClientRect(); + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + if (position.left < 0) main.style.left = 0; // 左はみ出し + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し + if (position.top < 0) main.style.top = 0; // 上はみ出し + } + } +}); +</script> + +<style lang="scss" scoped> +.window-enter-active, .window-leave-active { + transition: opacity 0.2s, transform 0.2s !important; +} +.window-enter-from, .window-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.ebkgocck { + position: fixed; + top: 0; + left: 0; + z-index: 10000; // mk-modalのと同じでなければならない + + &.front { + z-index: 11000; // front指定の時は、mk-modalのよりも大きくなければならない + } + + > .body { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + width: 100%; + height: 100%; + + > .header { + --height: 50px; + + &.mini { + --height: 38px; + } + + display: flex; + position: relative; + z-index: 1; + flex-shrink: 0; + user-select: none; + height: var(--height); + border-bottom: solid 1px var(--divider); + + > .left, > .right { + > ::v-deep(button) { + height: var(--height); + width: var(--height); + + &:hover { + color: var(--fgHighlighted); + } + } + } + + > .title { + flex: 1; + position: relative; + line-height: var(--height); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + cursor: move; + } + } + + > .body { + flex: 1; + overflow: auto; + } + } + + > .handle { + $size: 8px; + + position: absolute; + + &.top { + top: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.right { + top: 0; + right: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.bottom { + bottom: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.left { + top: 0; + left: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.top-left { + top: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.top-right { + top: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + + &.bottom-right { + bottom: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.bottom-left { + bottom: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + } +} +</style> diff --git a/packages/client/src/components/updated.vue b/packages/client/src/components/updated.vue new file mode 100644 index 0000000000..c021c60669 --- /dev/null +++ b/packages/client/src/components/updated.vue @@ -0,0 +1,62 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="ewlycnyt"> + <div class="title">{{ $ts.misskeyUpdated }}</div> + <div class="version">✨{{ version }}🚀</div> + <MkButton full @click="whatIsNew">{{ $ts.whatIsNew }}</MkButton> + <MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ $ts.gotIt }}</MkButton> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkButton from '@/components/ui/button.vue'; +import { version } from '@/config'; + +export default defineComponent({ + components: { + MkModal, + MkButton, + }, + + data() { + return { + version: version, + }; + }, + + methods: { + whatIsNew() { + this.$refs.modal.close(); + window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.ewlycnyt { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .title { + font-weight: bold; + } + + > .version { + margin: 1em 0; + } + + > .gotIt { + margin: 8px 0 0 0; + } +} +</style> diff --git a/packages/client/src/components/url-preview-popup.vue b/packages/client/src/components/url-preview-popup.vue new file mode 100644 index 0000000000..0a402f793f --- /dev/null +++ b/packages/client/src/components/url-preview-popup.vue @@ -0,0 +1,60 @@ +<template> +<div class="fgmtyycl" :style="{ top: top + 'px', left: left + 'px' }"> + <transition name="zoom" @after-leave="$emit('closed')"> + <MkUrlPreview class="_popup _shadow" :url="url" v-if="showing"/> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkUrlPreview from './url-preview.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkUrlPreview + }, + + props: { + url: { + type: String, + required: true + }, + source: { + required: true + }, + showing: { + type: Boolean, + required: true + }, + }, + + data() { + return { + u: null, + top: 0, + left: 0, + }; + }, + + mounted() { + const rect = this.source.getBoundingClientRect(); + const x = Math.max((rect.left + (this.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; + const y = rect.top + this.source.offsetHeight + window.pageYOffset; + + this.top = y; + this.left = x; + }, +}); +</script> + +<style lang="scss" scoped> +.fgmtyycl { + position: absolute; + z-index: 11000; + width: 500px; + max-width: calc(90vw - 12px); + pointer-events: none; +} +</style> diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue new file mode 100644 index 0000000000..0826ba5ccf --- /dev/null +++ b/packages/client/src/components/url-preview.vue @@ -0,0 +1,334 @@ +<template> +<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> + <button class="disablePlayer" @click="playerEnabled = false" :title="$ts.disablePlayer"><i class="fas fa-times"></i></button> + <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> +</div> +<div v-else-if="tweetId && tweetExpanded" class="twitter" ref="twitter"> + <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> +</div> +<div v-else class="mk-url-preview" v-size="{ max: [400, 350] }"> + <transition name="zoom" mode="out-in"> + <component :is="self ? 'MkA' : 'a'" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching"> + <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"> + <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$ts.enablePlayer"><i class="fas fa-play-circle"></i></button> + </div> + <article> + <header> + <h1 :title="title">{{ title }}</h1> + </header> + <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> + <footer> + <img class="icon" v-if="icon" :src="icon"/> + <p :title="sitename">{{ sitename }}</p> + </footer> + </article> + </component> + </transition> + <div class="expandTweet" v-if="tweetId"> + <a @click="tweetExpanded = true"> + <i class="fab fa-twitter"></i> {{ $ts.expandTweet }} + </a> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { url as local, lang } from '@/config'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + url: { + type: String, + require: true + }, + + detail: { + type: Boolean, + required: false, + default: false + }, + + compact: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + const self = this.url.startsWith(local); + return { + local, + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null, + player: { + url: null, + width: null, + height: null + }, + tweetId: null, + tweetExpanded: this.detail, + embedId: `embed${Math.random().toString().replace(/\D/,'')}`, + tweetHeight: 150, + tweetLeft: 0, + playerEnabled: false, + self: self, + attr: self ? 'to' : 'href', + target: self ? null : '_blank', + }; + }, + + created() { + const requestUrl = new URL(this.url); + + if (requestUrl.hostname == 'twitter.com') { + const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/); + if (m) this.tweetId = m[1]; + } + + if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { + requestUrl.hostname = 'www.youtube.com'; + } + + const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP'); + + requestUrl.hash = ''; + + fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { + res.json().then(info => { + if (info.url == null) return; + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + this.fetching = false; + this.player = info.player; + }) + }); + + (window as any).addEventListener('message', this.adjustTweetHeight); + }, + + mounted() { + // 300pxないと絶対右にはみ出るので左に移動してしまう + const areaWidth = (this.$el as any)?.clientWidth; + if (areaWidth && areaWidth < 300) this.tweetLeft = areaWidth - 241; + }, + + methods: { + adjustTweetHeight(message: any) { + if (message.origin !== 'https://platform.twitter.com') return; + const embed = message.data?.['twttr.embed']; + if (embed?.method !== 'twttr.private.resize') return; + if (embed?.id !== this.embedId) return; + const height = embed?.params[0]?.height; + if (height) this.tweetHeight = height; + }, + }, + + beforeUnmount() { + (window as any).removeEventListener('message', this.adjustTweetHeight); + }, +}); +</script> + +<style lang="scss" scoped> +.player { + position: relative; + width: 100%; + + > button { + position: absolute; + top: -1.5em; + right: 0; + font-size: 1em; + width: 1.5em; + height: 1.5em; + padding: 0; + margin: 0; + color: var(--fg); + background: rgba(128, 128, 128, 0.2); + opacity: 0.7; + + &:hover { + opacity: 0.9; + } + } + + > iframe { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } +} + +.mk-url-preview { + &.max-width_400px { + > a { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + } + + &.max-width_350px { + > a { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + + &.compact { + > .thumbnail { + position: absolute; + width: 56px; + height: 100%; + } + + > article { + left: 56px; + width: calc(100% - 56px); + padding: 4px; + + > header { + margin-bottom: 2px; + } + + > footer { + margin-top: 2px; + } + } + } + } + } + + > a { + position: relative; + display: block; + font-size: 14px; + box-shadow: 0 0 0 1px var(--divider); + border-radius: 8px; + overflow: hidden; + + &:hover { + text-decoration: none; + border-color: rgba(0, 0, 0, 0.2); + + > article > header > h1 { + text-decoration: underline; + } + } + + > .thumbnail { + position: absolute; + width: 100px; + height: 100%; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + position: relative; + box-sizing: border-box; + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + } + } + + > p { + margin: 0; + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + &.compact { + > article { + > header h1, p, footer { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/user-info.vue b/packages/client/src/components/user-info.vue new file mode 100644 index 0000000000..ce82443b84 --- /dev/null +++ b/packages/client/src/components/user-info.vue @@ -0,0 +1,142 @@ +<template> +<div class="_panel vjnjpkug"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> + <p class="username"><MkAcct :user="user"/></p> + </div> + <div class="description"> + <div class="mfm" v-if="user.description"> + <Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> + </div> + <span v-else style="opacity: 0.7;">{{ $ts.noAccountDescription }}</span> + </div> + <div class="status"> + <div> + <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> + </div> + </div> + <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkFollowButton from './follow-button.vue'; +import { userPage } from '@/filters/user'; + +export default defineComponent({ + components: { + MkFollowButton + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + }; + }, + + methods: { + userPage, + } +}); +</script> + +<style lang="scss" scoped> +.vjnjpkug { + position: relative; + + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 4px var(--panel); + } + + > .title { + display: block; + padding: 10px 0 10px 88px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + opacity: 0.7; + } + } + + > .description { + padding: 16px; + font-size: 0.8em; + border-top: solid 0.5px var(--divider); + + > .mfm { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + > .status { + padding: 10px 16px; + border-top: solid 0.5px var(--divider); + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--fg); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/packages/client/src/components/user-list.vue b/packages/client/src/components/user-list.vue new file mode 100644 index 0000000000..733dbe0ad7 --- /dev/null +++ b/packages/client/src/components/user-list.vue @@ -0,0 +1,91 @@ +<template> +<MkError v-if="error" @retry="init()"/> + +<div v-else class="efvhhmdq _isolated"> + <div class="no-users" v-if="empty"> + <p>{{ $ts.noUsers }}</p> + </div> + <div class="users"> + <MkUserInfo class="user" v-for="user in users" :user="user" :key="user.id"/> + </div> + <button class="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :class="{ fetching: moreFetching }" v-show="more" :disabled="moreFetching"> + <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }} + </button> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import MkUserInfo from './user-info.vue'; +import { userPage } from '@/filters/user'; + +export default defineComponent({ + components: { + MkUserInfo, + }, + + 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 + } +}); +</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; + } + } +} +</style> diff --git a/packages/client/src/components/user-online-indicator.vue b/packages/client/src/components/user-online-indicator.vue new file mode 100644 index 0000000000..afaf0e8736 --- /dev/null +++ b/packages/client/src/components/user-online-indicator.vue @@ -0,0 +1,50 @@ +<template> +<div class="fzgwjkgc" :class="user.onlineStatus" v-tooltip="text"></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + }, + + computed: { + text(): string { + switch (this.user.onlineStatus) { + case 'online': return this.$ts.online; + case 'active': return this.$ts.active; + case 'offline': return this.$ts.offline; + case 'unknown': return this.$ts.unknown; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.fzgwjkgc { + box-shadow: 0 0 0 3px var(--panel); + border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる + + &.online { + background: #58d4c9; + } + + &.active { + background: #e4bc48; + } + + &.offline { + background: #ea5353; + } + + &.unknown { + background: #888; + } +} +</style> diff --git a/packages/client/src/components/user-preview.vue b/packages/client/src/components/user-preview.vue new file mode 100644 index 0000000000..f7fd3f6b64 --- /dev/null +++ b/packages/client/src/components/user-preview.vue @@ -0,0 +1,192 @@ +<template> +<transition name="popup" appear @after-leave="$emit('closed')"> + <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> + <div v-if="fetched" class="info"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> + <p class="username"><MkAcct :user="user"/></p> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> + </div> + <div class="status"> + <div> + <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> + </div> + </div> + <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/> + </div> + <div v-else> + <MkLoading/> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkFollowButton from './follow-button.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkFollowButton + }, + + props: { + showing: { + type: Boolean, + required: true + }, + q: { + type: String, + required: true + }, + source: { + required: true + } + }, + + emits: ['closed', 'mouseover', 'mouseleave'], + + data() { + return { + user: null, + fetched: false, + top: 0, + left: 0, + }; + }, + + mounted() { + if (typeof this.q == 'object') { + this.user = this.q; + this.fetched = true; + } else { + const query = this.q.startsWith('@') ? + Acct.parse(this.q.substr(1)) : + { userId: this.q }; + + os.api('users/show', query).then(user => { + if (!this.showing) return; + this.user = user; + this.fetched = true; + }); + } + + const rect = this.source.getBoundingClientRect(); + const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; + const y = rect.top + this.source.offsetHeight + window.pageYOffset; + + this.top = y; + this.left = x; + }, + + methods: { + userPage + } +}); +</script> + +<style lang="scss" scoped> +.popup-enter-active, .popup-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.popup-enter-from, .popup-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.fxxzrfni { + position: absolute; + z-index: 11000; + width: 300px; + overflow: hidden; + transform-origin: center top; + + > .info { + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 3px var(--face); + border-radius: 8px; + } + + > .title { + display: block; + padding: 8px 0 8px 82px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + opacity: 0.7; + } + } + + > .description { + padding: 0 16px; + font-size: 0.8em; + color: var(--fg); + } + + > .status { + padding: 8px 16px; + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--fg); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } + } +} +</style> diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/user-select-dialog.vue new file mode 100644 index 0000000000..80f6293563 --- /dev/null +++ b/packages/client/src/components/user-select-dialog.vue @@ -0,0 +1,199 @@ +<template> +<XModalWindow ref="dialog" + :with-ok-button="true" + :ok-button-disabled="selected == null" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.selectUser }}</template> + <div class="tbhwbxda _monolithic_"> + <div class="_section"> + <div class="_inputSplit"> + <MkInput v-model="username" class="input" @update:modelValue="search" ref="username"> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + </MkInput> + <MkInput v-model="host" class="input" @update:modelValue="search"> + <template #label>{{ $ts.host }}</template> + <template #prefix>@</template> + </MkInput> + </div> + </div> + <div class="_section result" v-if="username != '' || host != ''" :class="{ hit: users.length > 0 }"> + <div class="users" v-if="users.length > 0"> + <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + </div> + </div> + <div v-else class="empty"> + <span>{{ $ts.noUsers }}</span> + </div> + </div> + <div class="_section recent" v-if="username == '' && host == ''"> + <div class="users"> + <div class="user" v-for="user in recentUsers" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + </div> + </div> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkInput from './form/input.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkInput, + XModalWindow, + }, + + props: { + }, + + emits: ['ok', 'cancel', 'closed'], + + data() { + return { + username: '', + host: '', + recentUsers: [], + users: [], + selected: null, + }; + }, + + async mounted() { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + + this.recentUsers = await os.api('users/show', { + userIds: this.$store.state.recentlyUsedUsers + }); + }, + + methods: { + search() { + if (this.username == '' && this.host == '') { + this.users = []; + return; + } + os.api('users/search-by-username-and-host', { + username: this.username, + host: this.host, + limit: 10, + detail: false + }).then(users => { + this.users = users; + }); + }, + + focus() { + this.$refs.username.focus(); + }, + + ok() { + this.$emit('ok', this.selected); + this.$refs.dialog.close(); + + // 最近使ったユーザー更新 + let recents = this.$store.state.recentlyUsedUsers; + recents = recents.filter(x => x !== this.selected.id); + recents.unshift(this.selected.id); + this.$store.set('recentlyUsedUsers', recents.splice(0, 16)); + }, + + cancel() { + this.$emit('cancel'); + this.$refs.dialog.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.tbhwbxda { + > ._section { + display: flex; + flex-direction: column; + overflow: auto; + height: 100%; + + &.result.hit { + padding: 0; + } + + &.recent { + padding: 0; + } + + > .users { + flex: 1; + overflow: auto; + padding: 8px 0; + + > .user { + display: flex; + align-items: center; + padding: 8px var(--root-margin); + font-size: 14px; + + &:hover { + background: var(--X7); + } + + &.selected { + background: var(--accent); + color: #fff; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + min-width: 0; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + + > .empty { + opacity: 0.7; + text-align: center; + } + } +} +</style> diff --git a/packages/client/src/components/users-dialog.vue b/packages/client/src/components/users-dialog.vue new file mode 100644 index 0000000000..6eec5289b3 --- /dev/null +++ b/packages/client/src/components/users-dialog.vue @@ -0,0 +1,147 @@ +<template> +<div class="mk-users-dialog"> + <div class="header"> + <span>{{ title }}</span> + <button class="_button" @click="close()"><i class="fas fa-times"></i></button> + </div> + + <div class="users"> + <MkA v-for="item in items" class="user" :key="item.id" :to="userPage(extract ? extract(item) : item)"> + <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="extract ? extract(item) : item" class="name"/> + <MkAcct :user="extract ? extract(item) : item" class="acct"/> + </div> + </MkA> + </div> + <button class="more _button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> + </button> + + <p class="empty" v-if="empty">{{ $ts.noUsers }}</p> + + <MkError v-if="error" @retry="init()"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import { userPage } from '@/filters/user'; + +export default defineComponent({ + mixins: [ + paging({}), + ], + + props: { + title: { + required: true + }, + pagination: { + required: true + }, + extract: { + required: false + } + }, + + data() { + return { + }; + }, + + methods: { + userPage + } +}); +</script> + +<style lang="scss" scoped> +.mk-users-dialog { + width: 350px; + height: 350px; + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + + > .header { + display: flex; + flex-shrink: 0; + + > button { + height: 58px; + width: 58px; + + @media (max-width: 500px) { + height: 42px; + width: 42px; + } + } + + > span { + flex: 1; + line-height: 58px; + padding-left: 32px; + font-weight: bold; + + @media (max-width: 500px) { + line-height: 42px; + padding-left: 16px; + } + } + } + + > .users { + flex: 1; + overflow: auto; + + &:empty { + display: none; + } + + > .user { + display: flex; + align-items: center; + font-size: 14px; + padding: 8px 32px; + + @media (max-width: 500px) { + padding: 8px 16px; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + overflow: hidden; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + + > .empty { + text-align: center; + opacity: 0.5; + } +} +</style> diff --git a/packages/client/src/components/visibility-picker.vue b/packages/client/src/components/visibility-picker.vue new file mode 100644 index 0000000000..7a811b42f7 --- /dev/null +++ b/packages/client/src/components/visibility-picker.vue @@ -0,0 +1,167 @@ +<template> +<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="gqyayizv _popup"> + <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="1" key="public"> + <div><i class="fas fa-globe"></i></div> + <div> + <span>{{ $ts._visibility.public }}</span> + <span>{{ $ts._visibility.publicDescription }}</span> + </div> + </button> + <button class="_button" @click="choose('home')" :class="{ active: v == 'home' }" data-index="2" key="home"> + <div><i class="fas fa-home"></i></div> + <div> + <span>{{ $ts._visibility.home }}</span> + <span>{{ $ts._visibility.homeDescription }}</span> + </div> + </button> + <button class="_button" @click="choose('followers')" :class="{ active: v == 'followers' }" data-index="3" key="followers"> + <div><i class="fas fa-unlock"></i></div> + <div> + <span>{{ $ts._visibility.followers }}</span> + <span>{{ $ts._visibility.followersDescription }}</span> + </div> + </button> + <button :disabled="localOnly" class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="4" key="specified"> + <div><i class="fas fa-envelope"></i></div> + <div> + <span>{{ $ts._visibility.specified }}</span> + <span>{{ $ts._visibility.specifiedDescription }}</span> + </div> + </button> + <div class="divider"></div> + <button class="_button localOnly" @click="localOnly = !localOnly" :class="{ active: localOnly }" data-index="5" key="localOnly"> + <div><i class="fas fa-biohazard"></i></div> + <div> + <span>{{ $ts._visibility.localOnly }}</span> + <span>{{ $ts._visibility.localOnlyDescription }}</span> + </div> + <div><i :class="localOnly ? 'fas fa-toggle-on' : 'fas fa-toggle-off'"></i></div> + </button> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; + +export default defineComponent({ + components: { + MkModal, + }, + props: { + currentVisibility: { + type: String, + required: true + }, + currentLocalOnly: { + type: Boolean, + required: true + }, + src: { + required: false + }, + }, + emits: ['change-visibility', 'change-local-only', 'closed'], + data() { + return { + v: this.currentVisibility, + localOnly: this.currentLocalOnly, + } + }, + watch: { + localOnly() { + this.$emit('change-local-only', this.localOnly); + } + }, + methods: { + choose(visibility) { + this.v = visibility; + this.$emit('change-visibility', visibility); + this.$nextTick(() => { + this.$refs.modal.close(); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.gqyayizv { + width: 240px; + padding: 8px 0; + + > .divider { + margin: 8px 0; + border-top: solid 0.5px var(--divider); + } + + > button { + display: flex; + padding: 8px 14px; + font-size: 12px; + text-align: left; + width: 100%; + box-sizing: border-box; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: rgba(0, 0, 0, 0.1); + } + + &.active { + color: #fff; + background: var(--accent); + } + + &.localOnly.active { + color: var(--accent); + background: inherit; + } + + > *:nth-child(1) { + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + + > *:nth-child(2) { + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > span:first-child { + display: block; + font-weight: bold; + } + + > span:last-child:not(:first-child) { + opacity: 0.6; + } + } + + > *:nth-child(3) { + display: flex; + justify-content: center; + align-items: center; + margin-left: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + } +} +</style> diff --git a/packages/client/src/components/waiting-dialog.vue b/packages/client/src/components/waiting-dialog.vue new file mode 100644 index 0000000000..35a760ea41 --- /dev/null +++ b/packages/client/src/components/waiting-dialog.vue @@ -0,0 +1,92 @@ +<template> +<MkModal ref="modal" @click="success ? done() : () => {}" @closed="$emit('closed')"> + <div class="iuyakobc" :class="{ iconOnly: (text == null) || success }"> + <i v-if="success" class="fas fa-check icon success"></i> + <i v-else class="fas fa-spinner fa-pulse icon waiting"></i> + <div class="text" v-if="text && !success">{{ text }}<MkEllipsis/></div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; + +export default defineComponent({ + components: { + MkModal, + }, + + props: { + success: { + type: Boolean, + required: true, + }, + showing: { + type: Boolean, + required: true, + }, + text: { + type: String, + required: false, + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + }; + }, + + watch: { + showing() { + if (!this.showing) this.done(); + } + }, + + methods: { + done() { + this.$emit('done'); + this.$refs.modal.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.iuyakobc { + position: relative; + padding: 32px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + width: 250px; + + &.iconOnly { + padding: 0; + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; + } + + > .icon { + font-size: 32px; + + &.success { + color: var(--accent); + } + + &.waiting { + opacity: 0.7; + } + } + + > .text { + margin-top: 16px; + } +} +</style> diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue new file mode 100644 index 0000000000..8aec77796d --- /dev/null +++ b/packages/client/src/components/widgets.vue @@ -0,0 +1,152 @@ +<template> +<div class="vjoppmmu"> + <template v-if="edit"> + <header> + <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)"> + <template #label>{{ $ts.selectWidget }}</template> + <option v-for="widget in widgetDefs" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option> + </MkSelect> + <MkButton inline @click="addWidget" primary><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton> + </header> + <XDraggable + v-model="_widgets" + item-key="id" + animation="150" + > + <template #item="{element}"> + <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)"/> + </div> + </template> + </XDraggable> + </template> + <component v-else class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" @updateProps="updateWidget(widget.id, $event)"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import MkSelect from '@/components/form/select.vue'; +import MkButton from '@/components/ui/button.vue'; +import { widgets as widgetDefs } from '@/widgets'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + MkSelect, + MkButton, + }, + + props: { + widgets: { + type: Array, + required: true, + }, + edit: { + type: Boolean, + required: true, + }, + }, + + emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'], + + data() { + return { + widgetAdderSelected: null, + widgetDefs, + settings: {}, + }; + }, + + computed: { + _widgets: { + get() { + return this.widgets; + }, + set(value) { + this.$emit('updateWidgets', value); + } + } + }, + + methods: { + configWidget(id) { + this.settings[id](); + }, + + addWidget() { + if (this.widgetAdderSelected == null) return; + + this.$emit('addWidget', { + name: this.widgetAdderSelected, + id: uuid(), + data: {} + }); + + this.widgetAdderSelected = null; + }, + + removeWidget(widget) { + this.$emit('removeWidget', widget); + }, + + updateWidget(id, data) { + this.$emit('updateWidget', { id, data }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.vjoppmmu { + > header { + margin: 16px 0; + + > * { + width: 100%; + padding: 4px; + } + } + + > .widget, .customize-container { + margin: var(--margin) 0; + + &:first-of-type { + margin-top: 0; + } + } + + .customize-container { + position: relative; + cursor: move; + + > *:not(.remove):not(.config) { + pointer-events: none; + } + + > .config, + > .remove { + position: absolute; + z-index: 10000; + top: 8px; + width: 32px; + height: 32px; + color: #fff; + background: rgba(#000, 0.7); + border-radius: 4px; + } + + > .config { + right: 8px + 8px + 32px; + } + + > .remove { + right: 8px; + } + } +} +</style> |