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 /src/client/components | |
| parent | update deps (diff) | |
| download | sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'src/client/components')
174 files changed, 0 insertions, 26274 deletions
diff --git a/src/client/components/abuse-report-window.vue b/src/client/components/abuse-report-window.vue deleted file mode 100644 index 21a19385ae..0000000000 --- a/src/client/components/abuse-report-window.vue +++ /dev/null @@ -1,79 +0,0 @@ -<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 '@client/components/ui/window.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/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/src/client/components/analog-clock.vue b/src/client/components/analog-clock.vue deleted file mode 100644 index bc572e5fff..0000000000 --- a/src/client/components/analog-clock.vue +++ /dev/null @@ -1,150 +0,0 @@ -<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/src/client/components/autocomplete.vue b/src/client/components/autocomplete.vue deleted file mode 100644 index e621b26229..0000000000 --- a/src/client/components/autocomplete.vue +++ /dev/null @@ -1,502 +0,0 @@ -<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 '@/misc/emojilist'; -import contains from '@client/scripts/contains'; -import { twemojiSvgBase } from '@/misc/twemoji-base'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import { acct } from '@client/filters/user'; -import * as os from '@client/os'; -import { instance } from '@client/instance'; - -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 - -const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle']; - -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/src/client/components/avatars.vue b/src/client/components/avatars.vue deleted file mode 100644 index da862967dd..0000000000 --- a/src/client/components/avatars.vue +++ /dev/null @@ -1,30 +0,0 @@ -<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 '@client/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/src/client/components/captcha.vue b/src/client/components/captcha.vue deleted file mode 100644 index baa922506e..0000000000 --- a/src/client/components/captcha.vue +++ /dev/null @@ -1,123 +0,0 @@ -<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/src/client/components/channel-follow-button.vue b/src/client/components/channel-follow-button.vue deleted file mode 100644 index bd8627f6e8..0000000000 --- a/src/client/components/channel-follow-button.vue +++ /dev/null @@ -1,140 +0,0 @@ -<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 '@client/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/src/client/components/channel-preview.vue b/src/client/components/channel-preview.vue deleted file mode 100644 index eb00052a78..0000000000 --- a/src/client/components/channel-preview.vue +++ /dev/null @@ -1,165 +0,0 @@ -<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/src/client/components/chart.vue b/src/client/components/chart.vue deleted file mode 100644 index ae9a5e79b1..0000000000 --- a/src/client/components/chart.vue +++ /dev/null @@ -1,691 +0,0 @@ -<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 '@client/os'; -import { defaultStore } from '@client/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/src/client/components/code-core.vue b/src/client/components/code-core.vue deleted file mode 100644 index 9cff7b4448..0000000000 --- a/src/client/components/code-core.vue +++ /dev/null @@ -1,35 +0,0 @@ -<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/src/client/components/code.vue b/src/client/components/code.vue deleted file mode 100644 index f5d6c5673a..0000000000 --- a/src/client/components/code.vue +++ /dev/null @@ -1,27 +0,0 @@ -<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/src/client/components/cw-button.vue b/src/client/components/cw-button.vue deleted file mode 100644 index 3a172f5d5e..0000000000 --- a/src/client/components/cw-button.vue +++ /dev/null @@ -1,70 +0,0 @@ -<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 '../../prelude/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/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue deleted file mode 100644 index fa0b6d669c..0000000000 --- a/src/client/components/date-separated-list.vue +++ /dev/null @@ -1,188 +0,0 @@ -<script lang="ts"> -import { defineComponent, h, PropType, TransitionGroup } from 'vue'; -import MkAd from '@client/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/src/client/components/debobigego/base.vue b/src/client/components/debobigego/base.vue deleted file mode 100644 index f551a3478b..0000000000 --- a/src/client/components/debobigego/base.vue +++ /dev/null @@ -1,65 +0,0 @@ -<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/src/client/components/debobigego/button.vue b/src/client/components/debobigego/button.vue deleted file mode 100644 index b883e817a4..0000000000 --- a/src/client/components/debobigego/button.vue +++ /dev/null @@ -1,81 +0,0 @@ -<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/src/client/components/debobigego/debobigego.scss b/src/client/components/debobigego/debobigego.scss deleted file mode 100644 index 833b656b66..0000000000 --- a/src/client/components/debobigego/debobigego.scss +++ /dev/null @@ -1,52 +0,0 @@ -._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/src/client/components/debobigego/group.vue b/src/client/components/debobigego/group.vue deleted file mode 100644 index cba2c6ec94..0000000000 --- a/src/client/components/debobigego/group.vue +++ /dev/null @@ -1,78 +0,0 @@ -<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/src/client/components/debobigego/info.vue b/src/client/components/debobigego/info.vue deleted file mode 100644 index 41afb03304..0000000000 --- a/src/client/components/debobigego/info.vue +++ /dev/null @@ -1,47 +0,0 @@ -<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/src/client/components/debobigego/input.vue b/src/client/components/debobigego/input.vue deleted file mode 100644 index d113f04d27..0000000000 --- a/src/client/components/debobigego/input.vue +++ /dev/null @@ -1,292 +0,0 @@ -<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/src/client/components/debobigego/key-value-view.vue b/src/client/components/debobigego/key-value-view.vue deleted file mode 100644 index 0e034a2d54..0000000000 --- a/src/client/components/debobigego/key-value-view.vue +++ /dev/null @@ -1,38 +0,0 @@ -<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/src/client/components/debobigego/link.vue b/src/client/components/debobigego/link.vue deleted file mode 100644 index 885579eadf..0000000000 --- a/src/client/components/debobigego/link.vue +++ /dev/null @@ -1,103 +0,0 @@ -<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/src/client/components/debobigego/object-view.vue b/src/client/components/debobigego/object-view.vue deleted file mode 100644 index ea79daa915..0000000000 --- a/src/client/components/debobigego/object-view.vue +++ /dev/null @@ -1,102 +0,0 @@ -<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/src/client/components/debobigego/pagination.vue b/src/client/components/debobigego/pagination.vue deleted file mode 100644 index 2166f5065f..0000000000 --- a/src/client/components/debobigego/pagination.vue +++ /dev/null @@ -1,42 +0,0 @@ -<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 '@client/scripts/paging'; - -export default defineComponent({ - components: { - FormButton, - FormGroup, - }, - - mixins: [ - paging({}), - ], - - props: { - pagination: { - required: true - }, - }, -}); -</script> - -<style lang="scss" scoped> -.uljviswt { -} -</style> diff --git a/src/client/components/debobigego/radios.vue b/src/client/components/debobigego/radios.vue deleted file mode 100644 index 071c013afb..0000000000 --- a/src/client/components/debobigego/radios.vue +++ /dev/null @@ -1,112 +0,0 @@ -<script lang="ts"> -import { defineComponent, h } from 'vue'; -import MkRadio from '@client/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/src/client/components/debobigego/range.vue b/src/client/components/debobigego/range.vue deleted file mode 100644 index 26fb0f37c6..0000000000 --- a/src/client/components/debobigego/range.vue +++ /dev/null @@ -1,122 +0,0 @@ -<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/src/client/components/debobigego/select.vue b/src/client/components/debobigego/select.vue deleted file mode 100644 index 7a31371afc..0000000000 --- a/src/client/components/debobigego/select.vue +++ /dev/null @@ -1,145 +0,0 @@ -<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/src/client/components/debobigego/suspense.vue b/src/client/components/debobigego/suspense.vue deleted file mode 100644 index e59e0ba12d..0000000000 --- a/src/client/components/debobigego/suspense.vue +++ /dev/null @@ -1,101 +0,0 @@ -<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 '@client/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/src/client/components/debobigego/switch.vue b/src/client/components/debobigego/switch.vue deleted file mode 100644 index 9a69e18302..0000000000 --- a/src/client/components/debobigego/switch.vue +++ /dev/null @@ -1,132 +0,0 @@ -<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/src/client/components/debobigego/textarea.vue b/src/client/components/debobigego/textarea.vue deleted file mode 100644 index 64e8d47126..0000000000 --- a/src/client/components/debobigego/textarea.vue +++ /dev/null @@ -1,161 +0,0 @@ -<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/src/client/components/debobigego/tuple.vue b/src/client/components/debobigego/tuple.vue deleted file mode 100644 index 8a4599fd64..0000000000 --- a/src/client/components/debobigego/tuple.vue +++ /dev/null @@ -1,36 +0,0 @@ -<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/src/client/components/dialog.vue b/src/client/components/dialog.vue deleted file mode 100644 index dd4932f61f..0000000000 --- a/src/client/components/dialog.vue +++ /dev/null @@ -1,212 +0,0 @@ -<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 '@client/components/ui/modal.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/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/src/client/components/drive-file-thumbnail.vue b/src/client/components/drive-file-thumbnail.vue deleted file mode 100644 index 2cb1d98618..0000000000 --- a/src/client/components/drive-file-thumbnail.vue +++ /dev/null @@ -1,108 +0,0 @@ -<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 '@client/components/img-with-blurhash.vue'; -import { ColdDeviceStorage } from '@client/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/src/client/components/drive-select-dialog.vue b/src/client/components/drive-select-dialog.vue deleted file mode 100644 index ce6e2fa789..0000000000 --- a/src/client/components/drive-select-dialog.vue +++ /dev/null @@ -1,70 +0,0 @@ -<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 '@client/components/ui/modal-window.vue'; -import number from '@client/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/src/client/components/drive-window.vue b/src/client/components/drive-window.vue deleted file mode 100644 index 30b04091be..0000000000 --- a/src/client/components/drive-window.vue +++ /dev/null @@ -1,44 +0,0 @@ -<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 '@client/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/src/client/components/drive.file.vue b/src/client/components/drive.file.vue deleted file mode 100644 index b1be3d0cab..0000000000 --- a/src/client/components/drive.file.vue +++ /dev/null @@ -1,374 +0,0 @@ -<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="/static-assets/client/label.svg"/> - <p>{{ $ts.avatar }}</p> - </div> - <div class="label" v-if="$i.bannerId == file.id"> - <img src="/static-assets/client/label.svg"/> - <p>{{ $ts.banner }}</p> - </div> - <div class="label red" v-if="file.isSensitive"> - <img src="/static-assets/client/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 '@client/scripts/copy-to-clipboard'; -import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; -import bytes from '@client/filters/bytes'; -import * as os from '@client/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('@client/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/src/client/components/drive.folder.vue b/src/client/components/drive.folder.vue deleted file mode 100644 index 4c09e7775a..0000000000 --- a/src/client/components/drive.folder.vue +++ /dev/null @@ -1,326 +0,0 @@ -<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 '@client/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/src/client/components/drive.nav-folder.vue b/src/client/components/drive.nav-folder.vue deleted file mode 100644 index 913a1b5f92..0000000000 --- a/src/client/components/drive.nav-folder.vue +++ /dev/null @@ -1,135 +0,0 @@ -<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 '@client/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/src/client/components/drive.vue b/src/client/components/drive.vue deleted file mode 100644 index 5dadf9a11f..0000000000 --- a/src/client/components/drive.vue +++ /dev/null @@ -1,784 +0,0 @@ -<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 '@client/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/src/client/components/emoji-picker-dialog.vue b/src/client/components/emoji-picker-dialog.vue deleted file mode 100644 index aa17b8b250..0000000000 --- a/src/client/components/emoji-picker-dialog.vue +++ /dev/null @@ -1,76 +0,0 @@ -<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 '@client/components/ui/popup.vue'; -import MkEmojiPicker from '@client/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/src/client/components/emoji-picker-window.vue b/src/client/components/emoji-picker-window.vue deleted file mode 100644 index b7b884565b..0000000000 --- a/src/client/components/emoji-picker-window.vue +++ /dev/null @@ -1,197 +0,0 @@ -<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 '@client/components/ui/window.vue'; -import MkEmojiPicker from '@client/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/src/client/components/emoji-picker.section.vue b/src/client/components/emoji-picker.section.vue deleted file mode 100644 index 0ea3761429..0000000000 --- a/src/client/components/emoji-picker.section.vue +++ /dev/null @@ -1,50 +0,0 @@ -<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 '@client/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/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue deleted file mode 100644 index 85a12a08e6..0000000000 --- a/src/client/components/emoji-picker.vue +++ /dev/null @@ -1,501 +0,0 @@ -<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 '@/misc/emojilist'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import Particle from '@client/components/particle.vue'; -import * as os from '@client/os'; -import { isDeviceTouch } from '@client/scripts/is-device-touch'; -import { isMobile } from '@client/scripts/is-mobile'; -import { emojiCategories } from '@client/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/src/client/components/featured-photos.vue b/src/client/components/featured-photos.vue deleted file mode 100644 index c992a5d1fb..0000000000 --- a/src/client/components/featured-photos.vue +++ /dev/null @@ -1,32 +0,0 @@ -<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 '@client/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/src/client/components/file-type-icon.vue b/src/client/components/file-type-icon.vue deleted file mode 100644 index 95200b98c2..0000000000 --- a/src/client/components/file-type-icon.vue +++ /dev/null @@ -1,28 +0,0 @@ -<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 '@client/os'; - -export default defineComponent({ - props: { - type: { - type: String, - required: true, - } - }, - data() { - return { - }; - }, - computed: { - kind(): string { - return this.type.split('/')[0]; - } - } -}); -</script> diff --git a/src/client/components/follow-button.vue b/src/client/components/follow-button.vue deleted file mode 100644 index 5eba9b1f6b..0000000000 --- a/src/client/components/follow-button.vue +++ /dev/null @@ -1,210 +0,0 @@ -<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 '@client/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/src/client/components/forgot-password.vue b/src/client/components/forgot-password.vue deleted file mode 100644 index 7fcf9aa720..0000000000 --- a/src/client/components/forgot-password.vue +++ /dev/null @@ -1,84 +0,0 @@ -<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 '@client/components/ui/modal-window.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/components/form-dialog.vue b/src/client/components/form-dialog.vue deleted file mode 100644 index 6353b7287e..0000000000 --- a/src/client/components/form-dialog.vue +++ /dev/null @@ -1,125 +0,0 @@ -<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 '@client/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/src/client/components/form/input.vue b/src/client/components/form/input.vue deleted file mode 100644 index 591eda9ed5..0000000000 --- a/src/client/components/form/input.vue +++ /dev/null @@ -1,315 +0,0 @@ -<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 '@client/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/src/client/components/form/radio.vue b/src/client/components/form/radio.vue deleted file mode 100644 index 0f31d8fa0a..0000000000 --- a/src/client/components/form/radio.vue +++ /dev/null @@ -1,122 +0,0 @@ -<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/src/client/components/form/radios.vue b/src/client/components/form/radios.vue deleted file mode 100644 index 998a738202..0000000000 --- a/src/client/components/form/radios.vue +++ /dev/null @@ -1,54 +0,0 @@ -<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/src/client/components/form/range.vue b/src/client/components/form/range.vue deleted file mode 100644 index 4cfe66a8fc..0000000000 --- a/src/client/components/form/range.vue +++ /dev/null @@ -1,139 +0,0 @@ -<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/src/client/components/form/section.vue b/src/client/components/form/section.vue deleted file mode 100644 index 8eac40a0db..0000000000 --- a/src/client/components/form/section.vue +++ /dev/null @@ -1,31 +0,0 @@ -<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/src/client/components/form/select.vue b/src/client/components/form/select.vue deleted file mode 100644 index 363b3515fa..0000000000 --- a/src/client/components/form/select.vue +++ /dev/null @@ -1,312 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import * as os from '@client/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/src/client/components/form/slot.vue b/src/client/components/form/slot.vue deleted file mode 100644 index 8580c1307d..0000000000 --- a/src/client/components/form/slot.vue +++ /dev/null @@ -1,50 +0,0 @@ -<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/src/client/components/form/switch.vue b/src/client/components/form/switch.vue deleted file mode 100644 index 85f8b7c870..0000000000 --- a/src/client/components/form/switch.vue +++ /dev/null @@ -1,150 +0,0 @@ -<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/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue deleted file mode 100644 index 048e9032df..0000000000 --- a/src/client/components/form/textarea.vue +++ /dev/null @@ -1,252 +0,0 @@ -<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 '@client/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/src/client/components/formula-core.vue b/src/client/components/formula-core.vue deleted file mode 100644 index 6e35295ff5..0000000000 --- a/src/client/components/formula-core.vue +++ /dev/null @@ -1,34 +0,0 @@ - -<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 '@client/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/src/client/components/formula.vue b/src/client/components/formula.vue deleted file mode 100644 index 6722ce38a1..0000000000 --- a/src/client/components/formula.vue +++ /dev/null @@ -1,23 +0,0 @@ -<template> -<XFormula :formula="formula" :block="block" /> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@client/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/src/client/components/gallery-post-preview.vue b/src/client/components/gallery-post-preview.vue deleted file mode 100644 index 5c3bdb1349..0000000000 --- a/src/client/components/gallery-post-preview.vue +++ /dev/null @@ -1,126 +0,0 @@ -<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 '@client/filters/user'; -import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; -import * as os from '@client/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/src/client/components/global/a.vue b/src/client/components/global/a.vue deleted file mode 100644 index 952dfb1841..0000000000 --- a/src/client/components/global/a.vue +++ /dev/null @@ -1,138 +0,0 @@ -<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 '@client/os'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { router } from '@client/router'; -import { url } from '@client/config'; -import { popout } from '@client/scripts/popout'; -import { ColdDeviceStorage } from '@client/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/src/client/components/global/acct.vue b/src/client/components/global/acct.vue deleted file mode 100644 index 70f2954cb0..0000000000 --- a/src/client/components/global/acct.vue +++ /dev/null @@ -1,38 +0,0 @@ -<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 '@client/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/src/client/components/global/ad.vue b/src/client/components/global/ad.vue deleted file mode 100644 index 8397b2229e..0000000000 --- a/src/client/components/global/ad.vue +++ /dev/null @@ -1,200 +0,0 @@ -<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 '@client/instance'; -import { host } from '@client/config'; -import MkButton from '@client/components/ui/button.vue'; -import { defaultStore } from '@client/store'; -import * as os from '@client/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/src/client/components/global/avatar.vue b/src/client/components/global/avatar.vue deleted file mode 100644 index 395ed5d8ce..0000000000 --- a/src/client/components/global/avatar.vue +++ /dev/null @@ -1,163 +0,0 @@ -<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 '@client/scripts/get-static-image-url'; -import { extractAvgColorFromBlurhash } from '@client/scripts/extract-avg-color-from-blurhash'; -import { acct, userPage } from '@client/filters/user'; -import MkUserOnlineIndicator from '@client/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/src/client/components/global/ellipsis.vue b/src/client/components/global/ellipsis.vue deleted file mode 100644 index 0a46f486d6..0000000000 --- a/src/client/components/global/ellipsis.vue +++ /dev/null @@ -1,34 +0,0 @@ -<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/src/client/components/global/emoji.vue b/src/client/components/global/emoji.vue deleted file mode 100644 index f92e35c38f..0000000000 --- a/src/client/components/global/emoji.vue +++ /dev/null @@ -1,125 +0,0 @@ -<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 '@client/scripts/get-static-image-url'; -import { twemojiSvgBase } from '@client/../misc/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/src/client/components/global/error.vue b/src/client/components/global/error.vue deleted file mode 100644 index 05a508a653..0000000000 --- a/src/client/components/global/error.vue +++ /dev/null @@ -1,46 +0,0 @@ -<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 '@client/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/src/client/components/global/header.vue b/src/client/components/global/header.vue deleted file mode 100644 index 526db07fd3..0000000000 --- a/src/client/components/global/header.vue +++ /dev/null @@ -1,360 +0,0 @@ -<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 '@client/os'; -import { url } from '@client/config'; -import { scrollToTop } from '@client/scripts/scroll'; -import MkButton from '@client/components/ui/button.vue'; -import { i18n } from '@client/i18n'; -import { globalEvents } from '@client/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/src/client/components/global/i18n.ts b/src/client/components/global/i18n.ts deleted file mode 100644 index abf0c96856..0000000000 --- a/src/client/components/global/i18n.ts +++ /dev/null @@ -1,42 +0,0 @@ -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/src/client/components/global/loading.vue b/src/client/components/global/loading.vue deleted file mode 100644 index 7bde53c12e..0000000000 --- a/src/client/components/global/loading.vue +++ /dev/null @@ -1,92 +0,0 @@ -<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/src/client/components/global/misskey-flavored-markdown.vue b/src/client/components/global/misskey-flavored-markdown.vue deleted file mode 100644 index c4f75bee93..0000000000 --- a/src/client/components/global/misskey-flavored-markdown.vue +++ /dev/null @@ -1,157 +0,0 @@ -<template> -<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MfmCore from '@client/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/src/client/components/global/spacer.vue b/src/client/components/global/spacer.vue deleted file mode 100644 index 1129d54c71..0000000000 --- a/src/client/components/global/spacer.vue +++ /dev/null @@ -1,76 +0,0 @@ -<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/src/client/components/global/sticky-container.vue b/src/client/components/global/sticky-container.vue deleted file mode 100644 index 859b2c1d73..0000000000 --- a/src/client/components/global/sticky-container.vue +++ /dev/null @@ -1,74 +0,0 @@ -<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/src/client/components/global/time.vue b/src/client/components/global/time.vue deleted file mode 100644 index 6a330a2307..0000000000 --- a/src/client/components/global/time.vue +++ /dev/null @@ -1,73 +0,0 @@ -<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/src/client/components/global/url.vue b/src/client/components/global/url.vue deleted file mode 100644 index 218729882d..0000000000 --- a/src/client/components/global/url.vue +++ /dev/null @@ -1,142 +0,0 @@ -<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 '@client/config'; -import { isDeviceTouch } from '@client/scripts/is-device-touch'; -import * as os from '@client/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('@client/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/src/client/components/global/user-name.vue b/src/client/components/global/user-name.vue deleted file mode 100644 index bc93a8ea30..0000000000 --- a/src/client/components/global/user-name.vue +++ /dev/null @@ -1,20 +0,0 @@ -<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/src/client/components/google.vue b/src/client/components/google.vue deleted file mode 100644 index be724f038d..0000000000 --- a/src/client/components/google.vue +++ /dev/null @@ -1,64 +0,0 @@ -<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 '@client/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/src/client/components/image-viewer.vue b/src/client/components/image-viewer.vue deleted file mode 100644 index 7701ae926f..0000000000 --- a/src/client/components/image-viewer.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/filters/bytes'; -import number from '@client/filters/number'; -import MkModal from '@client/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/src/client/components/img-with-blurhash.vue b/src/client/components/img-with-blurhash.vue deleted file mode 100644 index 7e80b00208..0000000000 --- a/src/client/components/img-with-blurhash.vue +++ /dev/null @@ -1,100 +0,0 @@ -<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/src/client/components/index.ts b/src/client/components/index.ts deleted file mode 100644 index 2340b228f8..0000000000 --- a/src/client/components/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -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/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue deleted file mode 100644 index fd0b75609f..0000000000 --- a/src/client/components/instance-stats.vue +++ /dev/null @@ -1,80 +0,0 @@ -<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 '@client/components/form/select.vue'; -import MkChart from '@client/components/chart.vue'; -import * as os from '@client/os'; -import { defaultStore } from '@client/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/src/client/components/instance-ticker.vue b/src/client/components/instance-ticker.vue deleted file mode 100644 index 5674174558..0000000000 --- a/src/client/components/instance-ticker.vue +++ /dev/null @@ -1,62 +0,0 @@ -<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 '@client/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/src/client/components/launch-pad.vue b/src/client/components/launch-pad.vue deleted file mode 100644 index 9da62f1e0b..0000000000 --- a/src/client/components/launch-pad.vue +++ /dev/null @@ -1,152 +0,0 @@ -<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 '@client/components/ui/modal.vue'; -import { menuDef } from '@client/menu'; -import { instanceName } from '@client/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/src/client/components/link.vue b/src/client/components/link.vue deleted file mode 100644 index a887410331..0000000000 --- a/src/client/components/link.vue +++ /dev/null @@ -1,92 +0,0 @@ -<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 '@client/config'; -import { isDeviceTouch } from '@client/scripts/is-device-touch'; -import * as os from '@client/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('@client/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/src/client/components/media-banner.vue b/src/client/components/media-banner.vue deleted file mode 100644 index 34065557bf..0000000000 --- a/src/client/components/media-banner.vue +++ /dev/null @@ -1,107 +0,0 @@ -<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 '@client/os'; -import { ColdDeviceStorage } from '@client/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/src/client/components/media-caption.vue b/src/client/components/media-caption.vue deleted file mode 100644 index b35b101d06..0000000000 --- a/src/client/components/media-caption.vue +++ /dev/null @@ -1,260 +0,0 @@ -<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 '@client/components/ui/modal.vue'; -import MkButton from '@client/components/ui/button.vue'; -import bytes from '@client/filters/bytes'; -import number from '@client/filters/number'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; - -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 - }; - }, - - mounted() { - document.addEventListener('keydown', this.onKeydown); - }, - - beforeUnmount() { - document.removeEventListener('keydown', this.onKeydown); - }, - - computed: { - remainingLength(): number { - if (typeof this.inputValue != "string") return DB_MAX_IMAGE_COMMENT_LENGTH; - return DB_MAX_IMAGE_COMMENT_LENGTH - length(this.inputValue); - } - }, - - 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/src/client/components/media-image.vue b/src/client/components/media-image.vue deleted file mode 100644 index fd5e0b5f9b..0000000000 --- a/src/client/components/media-image.vue +++ /dev/null @@ -1,155 +0,0 @@ -<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 '@client/scripts/get-static-image-url'; -import { extractAvgColorFromBlurhash } from '@client/scripts/extract-avg-color-from-blurhash'; -import ImageViewer from './image-viewer.vue'; -import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; -import * as os from '@client/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/src/client/components/media-list.vue b/src/client/components/media-list.vue deleted file mode 100644 index c499525d84..0000000000 --- a/src/client/components/media-list.vue +++ /dev/null @@ -1,167 +0,0 @@ -<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 '@client/os'; -import { defaultStore } from '@client/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/src/client/components/media-video.vue b/src/client/components/media-video.vue deleted file mode 100644 index 4d4a551653..0000000000 --- a/src/client/components/media-video.vue +++ /dev/null @@ -1,97 +0,0 @@ -<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 '@client/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/src/client/components/mention.vue b/src/client/components/mention.vue deleted file mode 100644 index 4c7030bf35..0000000000 --- a/src/client/components/mention.vue +++ /dev/null @@ -1,86 +0,0 @@ -<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 '@client/config'; -import { wellKnownServices } from '../../well-known-services'; -import { $i } from '@client/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 wellKnown = wellKnownServices.find(x => x[0] === props.host); - const url = wellKnown ? wellKnown[1](props.username) : `/${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/src/client/components/mfm.ts b/src/client/components/mfm.ts deleted file mode 100644 index ad6e711f6f..0000000000 --- a/src/client/components/mfm.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { VNode, defineComponent, h } from 'vue'; -import * as mfm from 'mfm-js'; -import MkUrl from '@client/components/global/url.vue'; -import MkLink from '@client/components/link.vue'; -import MkMention from '@client/components/mention.vue'; -import MkEmoji from '@client/components/global/emoji.vue'; -import { concat } from '@client/../prelude/array'; -import MkFormula from '@client/components/formula.vue'; -import MkCode from '@client/components/code.vue'; -import MkGoogle from '@client/components/google.vue'; -import MkSparkle from '@client/components/sparkle.vue'; -import MkA from '@client/components/global/a.vue'; -import { host } from '@client/config'; -import { fnNameList } from '@/mfm/fn-name-list'; - -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 }); - - 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/src/client/components/mini-chart.vue b/src/client/components/mini-chart.vue deleted file mode 100644 index 0d01e4e4b5..0000000000 --- a/src/client/components/mini-chart.vue +++ /dev/null @@ -1,90 +0,0 @@ -<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 '@client/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/src/client/components/modal-page-window.vue b/src/client/components/modal-page-window.vue deleted file mode 100644 index e47d3dc62c..0000000000 --- a/src/client/components/modal-page-window.vue +++ /dev/null @@ -1,223 +0,0 @@ -<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 '@client/components/ui/modal.vue'; -import { popout } from '@client/scripts/popout'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/symbols'; -import * as os from '@client/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/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue deleted file mode 100644 index 568a2360d1..0000000000 --- a/src/client/components/note-detailed.vue +++ /dev/null @@ -1,1229 +0,0 @@ -<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 '../../prelude/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 '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -// TODO: note.vueとほぼ同じなので共通化したい -export default defineComponent({ - components: { - XSub, - XNoteHeader, - XNoteSimple, - XReactionsViewer, - XMediaList, - XCwButton, - XPoll, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/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('@client/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/src/client/components/note-header.vue b/src/client/components/note-header.vue deleted file mode 100644 index 80bfea9b07..0000000000 --- a/src/client/components/note-header.vue +++ /dev/null @@ -1,115 +0,0 @@ -<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 '@client/filters/note'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/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/src/client/components/note-preview.vue b/src/client/components/note-preview.vue deleted file mode 100644 index a474a01341..0000000000 --- a/src/client/components/note-preview.vue +++ /dev/null @@ -1,98 +0,0 @@ -<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/src/client/components/note-simple.vue b/src/client/components/note-simple.vue deleted file mode 100644 index 406a475cd9..0000000000 --- a/src/client/components/note-simple.vue +++ /dev/null @@ -1,113 +0,0 @@ -<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 '@client/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/src/client/components/note.sub.vue b/src/client/components/note.sub.vue deleted file mode 100644 index 157b65ec5c..0000000000 --- a/src/client/components/note.sub.vue +++ /dev/null @@ -1,146 +0,0 @@ -<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 '@client/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/src/client/components/note.vue b/src/client/components/note.vue deleted file mode 100644 index 681e819a22..0000000000 --- a/src/client/components/note.vue +++ /dev/null @@ -1,1228 +0,0 @@ -<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 '../../prelude/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 '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -export default defineComponent({ - components: { - XSub, - XNoteHeader, - XNoteSimple, - XReactionsViewer, - XMediaList, - XCwButton, - XPoll, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/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('@client/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/src/client/components/notes.vue b/src/client/components/notes.vue deleted file mode 100644 index 919cb29952..0000000000 --- a/src/client/components/notes.vue +++ /dev/null @@ -1,130 +0,0 @@ -<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 '@client/scripts/paging'; -import XNote from './note.vue'; -import XList from './date-separated-list.vue'; -import MkButton from '@client/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/src/client/components/notification-setting-window.vue b/src/client/components/notification-setting-window.vue deleted file mode 100644 index 14e0b76cc6..0000000000 --- a/src/client/components/notification-setting-window.vue +++ /dev/null @@ -1,99 +0,0 @@ -<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 '@client/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 '@/types'; - -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/src/client/components/notification.vue b/src/client/components/notification.vue deleted file mode 100644 index ce1fa5b160..0000000000 --- a/src/client/components/notification.vue +++ /dev/null @@ -1,362 +0,0 @@ -<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 '@/misc/get-note-summary'; -import XReactionIcon from './reaction-icon.vue'; -import MkFollowButton from './follow-button.vue'; -import XReactionTooltip from './reaction-tooltip.vue'; -import notePage from '@client/filters/note'; -import { userPage } from '@client/filters/user'; -import { i18n } from '@client/i18n'; -import * as os from '@client/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/src/client/components/notifications.vue b/src/client/components/notifications.vue deleted file mode 100644 index 78c1cce0c7..0000000000 --- a/src/client/components/notifications.vue +++ /dev/null @@ -1,159 +0,0 @@ -<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 '@client/scripts/paging'; -import XNotification from './notification.vue'; -import XList from './date-separated-list.vue'; -import XNote from './note.vue'; -import { notificationTypes } from '@/types'; -import * as os from '@client/os'; -import MkButton from '@client/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/src/client/components/number-diff.vue b/src/client/components/number-diff.vue deleted file mode 100644 index 690f89dd59..0000000000 --- a/src/client/components/number-diff.vue +++ /dev/null @@ -1,47 +0,0 @@ -<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 '@client/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/src/client/components/page-preview.vue b/src/client/components/page-preview.vue deleted file mode 100644 index 090c4a6a6c..0000000000 --- a/src/client/components/page-preview.vue +++ /dev/null @@ -1,162 +0,0 @@ -<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 '@client/filters/user'; -import * as os from '@client/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/src/client/components/page-window.vue b/src/client/components/page-window.vue deleted file mode 100644 index bc7c5b7a19..0000000000 --- a/src/client/components/page-window.vue +++ /dev/null @@ -1,167 +0,0 @@ -<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 '@client/components/ui/window.vue'; -import { popout } from '@client/scripts/popout'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/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/src/client/components/page/page.block.vue b/src/client/components/page/page.block.vue deleted file mode 100644 index ffd9ce89f9..0000000000 --- a/src/client/components/page/page.block.vue +++ /dev/null @@ -1,44 +0,0 @@ -<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 '@client/scripts/hpml/evaluator'; -import { Block } from '@client/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/src/client/components/page/page.button.vue b/src/client/components/page/page.button.vue deleted file mode 100644 index c6ae675212..0000000000 --- a/src/client/components/page/page.button.vue +++ /dev/null @@ -1,66 +0,0 @@ -<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 '@client/os'; -import { ButtonBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.canvas.vue b/src/client/components/page/page.canvas.vue deleted file mode 100644 index e26db597f2..0000000000 --- a/src/client/components/page/page.canvas.vue +++ /dev/null @@ -1,49 +0,0 @@ -<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 '@client/os'; -import { CanvasBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.counter.vue b/src/client/components/page/page.counter.vue deleted file mode 100644 index dad7ac3da0..0000000000 --- a/src/client/components/page/page.counter.vue +++ /dev/null @@ -1,52 +0,0 @@ -<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 '@client/os'; -import { CounterVarBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.if.vue b/src/client/components/page/page.if.vue deleted file mode 100644 index a70525e07c..0000000000 --- a/src/client/components/page/page.if.vue +++ /dev/null @@ -1,31 +0,0 @@ -<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 '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.image.vue b/src/client/components/page/page.image.vue deleted file mode 100644 index 14dedc98a0..0000000000 --- a/src/client/components/page/page.image.vue +++ /dev/null @@ -1,40 +0,0 @@ -<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 '@client/os'; -import { ImageBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.note.vue b/src/client/components/page/page.note.vue deleted file mode 100644 index 7a3f88bb1f..0000000000 --- a/src/client/components/page/page.note.vue +++ /dev/null @@ -1,47 +0,0 @@ -<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 '@client/components/note.vue'; -import XNoteDetailed from '@client/components/note-detailed.vue'; -import * as os from '@client/os'; -import { NoteBlock } from '@client/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/src/client/components/page/page.number-input.vue b/src/client/components/page/page.number-input.vue deleted file mode 100644 index 5d9168f130..0000000000 --- a/src/client/components/page/page.number-input.vue +++ /dev/null @@ -1,55 +0,0 @@ -<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 '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { NumberInputVarBlock } from '@client/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/src/client/components/page/page.post.vue b/src/client/components/page/page.post.vue deleted file mode 100644 index c20d7cade1..0000000000 --- a/src/client/components/page/page.post.vue +++ /dev/null @@ -1,109 +0,0 @@ -<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 '@client/config'; -import * as os from '@client/os'; -import { PostBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.radio-button.vue b/src/client/components/page/page.radio-button.vue deleted file mode 100644 index 590e59d706..0000000000 --- a/src/client/components/page/page.radio-button.vue +++ /dev/null @@ -1,45 +0,0 @@ -<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 '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { RadioButtonVarBlock } from '@client/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/src/client/components/page/page.section.vue b/src/client/components/page/page.section.vue deleted file mode 100644 index 81cab12501..0000000000 --- a/src/client/components/page/page.section.vue +++ /dev/null @@ -1,60 +0,0 @@ -<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 '@client/os'; -import { SectionBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.switch.vue b/src/client/components/page/page.switch.vue deleted file mode 100644 index 4d74e5df39..0000000000 --- a/src/client/components/page/page.switch.vue +++ /dev/null @@ -1,55 +0,0 @@ -<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 '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { SwitchVarBlock } from '@client/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/src/client/components/page/page.text-input.vue b/src/client/components/page/page.text-input.vue deleted file mode 100644 index 6e9ac0b543..0000000000 --- a/src/client/components/page/page.text-input.vue +++ /dev/null @@ -1,55 +0,0 @@ -<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 '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { TextInputVarBlock } from '@client/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/src/client/components/page/page.text.vue b/src/client/components/page/page.text.vue deleted file mode 100644 index 580c5a93bf..0000000000 --- a/src/client/components/page/page.text.vue +++ /dev/null @@ -1,68 +0,0 @@ -<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 '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { defineAsyncComponent, defineComponent, PropType } from 'vue'; -import * as mfm from 'mfm-js'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -export default defineComponent({ - components: { - MkUrlPreview: defineAsyncComponent(() => import('@client/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/src/client/components/page/page.textarea-input.vue b/src/client/components/page/page.textarea-input.vue deleted file mode 100644 index dfcb398937..0000000000 --- a/src/client/components/page/page.textarea-input.vue +++ /dev/null @@ -1,47 +0,0 @@ -<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 '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { HpmlTextInput } from '@client/scripts/hpml'; -import { TextInputVarBlock } from '@client/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/src/client/components/page/page.textarea.vue b/src/client/components/page/page.textarea.vue deleted file mode 100644 index cf953bf041..0000000000 --- a/src/client/components/page/page.textarea.vue +++ /dev/null @@ -1,39 +0,0 @@ -<template> -<MkTextarea :model-value="text" readonly></MkTextarea> -</template> - -<script lang="ts"> -import { TextBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/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/src/client/components/page/page.vue b/src/client/components/page/page.vue deleted file mode 100644 index f125365c3d..0000000000 --- a/src/client/components/page/page.vue +++ /dev/null @@ -1,86 +0,0 @@ -<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 '@client/scripts/hpml/evaluator'; -import { url } from '@client/config'; -import { $i } from '@client/account'; -import { defaultStore } from '@client/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/src/client/components/particle.vue b/src/client/components/particle.vue deleted file mode 100644 index d82705c1e8..0000000000 --- a/src/client/components/particle.vue +++ /dev/null @@ -1,114 +0,0 @@ -<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/src/client/components/poll-editor.vue b/src/client/components/poll-editor.vue deleted file mode 100644 index b28a1c8baa..0000000000 --- a/src/client/components/poll-editor.vue +++ /dev/null @@ -1,251 +0,0 @@ -<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 '../../prelude/time'; -import { formatDateTimeString } from '@/misc/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/src/client/components/poll.vue b/src/client/components/poll.vue deleted file mode 100644 index b5d430f93b..0000000000 --- a/src/client/components/poll.vue +++ /dev/null @@ -1,174 +0,0 @@ -<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 '../../prelude/array'; -import * as os from '@client/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/src/client/components/post-form-attaches.vue b/src/client/components/post-form-attaches.vue deleted file mode 100644 index 9365365653..0000000000 --- a/src/client/components/post-form-attaches.vue +++ /dev/null @@ -1,193 +0,0 @@ -<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 '@client/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("@client/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/src/client/components/post-form-dialog.vue b/src/client/components/post-form-dialog.vue deleted file mode 100644 index aa23e3891e..0000000000 --- a/src/client/components/post-form-dialog.vue +++ /dev/null @@ -1,19 +0,0 @@ -<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 '@client/components/ui/modal.vue'; -import MkPostForm from '@client/components/post-form.vue'; - -export default defineComponent({ - components: { - MkModal, - MkPostForm, - }, - emits: ['closed'], -}); -</script> diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue deleted file mode 100644 index 90df78895c..0000000000 --- a/src/client/components/post-form.vue +++ /dev/null @@ -1,980 +0,0 @@ -<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 '@client/config'; -import { erase, unique } from '../../prelude/array'; -import { extractMentions } from '@/misc/extract-mentions'; -import { getAcct } from '@/misc/acct'; -import { formatTimeString } from '@/misc/format-time-string'; -import { Autocomplete } from '@client/scripts/autocomplete'; -import { noteVisibilities } from '../../types'; -import * as os from '@client/os'; -import { selectFile } from '@client/scripts/select-file'; -import { defaultStore, notePostInterruptors, postFormActions } from '@client/store'; -import { isMobile } from '@client/scripts/is-mobile'; -import { throttle } from 'throttle-debounce'; -import MkInfo from '@client/components/ui/info.vue'; -import { defaultStore } from '@client/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, '@' + getAcct(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/src/client/components/queue-chart.vue b/src/client/components/queue-chart.vue deleted file mode 100644 index f9c3eccfb5..0000000000 --- a/src/client/components/queue-chart.vue +++ /dev/null @@ -1,232 +0,0 @@ -<template> -<canvas ref="chartEl"></canvas> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; -import { - Chart, - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -} from 'chart.js'; -import number from '@client/filters/number'; -import * as os from '@client/os'; -import { defaultStore } from '@client/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/src/client/components/reaction-icon.vue b/src/client/components/reaction-icon.vue deleted file mode 100644 index c0ec955e32..0000000000 --- a/src/client/components/reaction-icon.vue +++ /dev/null @@ -1,25 +0,0 @@ -<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/src/client/components/reaction-tooltip.vue b/src/client/components/reaction-tooltip.vue deleted file mode 100644 index 93143cbe81..0000000000 --- a/src/client/components/reaction-tooltip.vue +++ /dev/null @@ -1,51 +0,0 @@ -<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/src/client/components/reactions-viewer.details.vue b/src/client/components/reactions-viewer.details.vue deleted file mode 100644 index 7c49bd1d9c..0000000000 --- a/src/client/components/reactions-viewer.details.vue +++ /dev/null @@ -1,91 +0,0 @@ -<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/src/client/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue deleted file mode 100644 index f47ba83f61..0000000000 --- a/src/client/components/reactions-viewer.reaction.vue +++ /dev/null @@ -1,183 +0,0 @@ -<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 '@client/components/reactions-viewer.details.vue'; -import XReactionIcon from '@client/components/reaction-icon.vue'; -import * as os from '@client/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/src/client/components/reactions-viewer.vue b/src/client/components/reactions-viewer.vue deleted file mode 100644 index 94a0318734..0000000000 --- a/src/client/components/reactions-viewer.vue +++ /dev/null @@ -1,48 +0,0 @@ -<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/src/client/components/remote-caution.vue b/src/client/components/remote-caution.vue deleted file mode 100644 index 985ae44694..0000000000 --- a/src/client/components/remote-caution.vue +++ /dev/null @@ -1,35 +0,0 @@ -<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 '@client/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/src/client/components/sample.vue b/src/client/components/sample.vue deleted file mode 100644 index c8b46a80e7..0000000000 --- a/src/client/components/sample.vue +++ /dev/null @@ -1,116 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkRadio from '@client/components/form/radio.vue'; -import * as os from '@client/os'; -import * as config from '@client/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/src/client/components/signin-dialog.vue b/src/client/components/signin-dialog.vue deleted file mode 100644 index 6c38c07d78..0000000000 --- a/src/client/components/signin-dialog.vue +++ /dev/null @@ -1,42 +0,0 @@ -<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 '@client/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/src/client/components/signin.vue b/src/client/components/signin.vue deleted file mode 100755 index d6e1ee8b68..0000000000 --- a/src/client/components/signin.vue +++ /dev/null @@ -1,240 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import { apiUrl, host } from '@client/config'; -import { byteify, hexify } from '@client/scripts/2fa'; -import * as os from '@client/os'; -import { login } from '@client/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('@client/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/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue deleted file mode 100644 index 9741e8c73b..0000000000 --- a/src/client/components/signup-dialog.vue +++ /dev/null @@ -1,50 +0,0 @@ -<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 '@client/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/src/client/components/signup.vue b/src/client/components/signup.vue deleted file mode 100644 index 8d4340fd36..0000000000 --- a/src/client/components/signup.vue +++ /dev/null @@ -1,268 +0,0 @@ -<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 '@client/config'; -import MkButton from './ui/button.vue'; -import MkInput from './form/input.vue'; -import MkSwitch from './form/switch.vue'; -import * as os from '@client/os'; -import { login } from '@client/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/src/client/components/sparkle.vue b/src/client/components/sparkle.vue deleted file mode 100644 index 942412b445..0000000000 --- a/src/client/components/sparkle.vue +++ /dev/null @@ -1,180 +0,0 @@ -<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 '@client/os'; - -const sprite = new Image(); -sprite.src = "/static-assets/client/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/src/client/components/sub-note-content.vue b/src/client/components/sub-note-content.vue deleted file mode 100644 index ff89a9887b..0000000000 --- a/src/client/components/sub-note-content.vue +++ /dev/null @@ -1,62 +0,0 @@ -<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 '@client/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/src/client/components/tab.vue b/src/client/components/tab.vue deleted file mode 100644 index c629727358..0000000000 --- a/src/client/components/tab.vue +++ /dev/null @@ -1,73 +0,0 @@ -<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/src/client/components/taskmanager.api-window.vue b/src/client/components/taskmanager.api-window.vue deleted file mode 100644 index 807e4a0075..0000000000 --- a/src/client/components/taskmanager.api-window.vue +++ /dev/null @@ -1,72 +0,0 @@ -<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 '@client/components/ui/window.vue'; -import MkTab from '@client/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/src/client/components/taskmanager.vue b/src/client/components/taskmanager.vue deleted file mode 100644 index 6f3d1b0354..0000000000 --- a/src/client/components/taskmanager.vue +++ /dev/null @@ -1,233 +0,0 @@ -<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 '@client/components/ui/window.vue'; -import MkTab from '@client/components/tab.vue'; -import MkButton from '@client/components/ui/button.vue'; -import follow from '@client/directives/follow-append'; -import * as os from '@client/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/src/client/components/timeline.vue b/src/client/components/timeline.vue deleted file mode 100644 index 9676616f2a..0000000000 --- a/src/client/components/timeline.vue +++ /dev/null @@ -1,183 +0,0 @@ -<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 '@client/os'; -import * as sound from '@client/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/src/client/components/toast.vue b/src/client/components/toast.vue deleted file mode 100644 index fb0de68092..0000000000 --- a/src/client/components/toast.vue +++ /dev/null @@ -1,73 +0,0 @@ -<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/src/client/components/token-generate-window.vue b/src/client/components/token-generate-window.vue deleted file mode 100644 index 86312564cc..0000000000 --- a/src/client/components/token-generate-window.vue +++ /dev/null @@ -1,117 +0,0 @@ -<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 { kinds } from '@/misc/api-permissions'; -import XModalWindow from '@client/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 - }; - }, - - 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/src/client/components/ui/button.vue b/src/client/components/ui/button.vue deleted file mode 100644 index b5f4547c84..0000000000 --- a/src/client/components/ui/button.vue +++ /dev/null @@ -1,262 +0,0 @@ -<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/src/client/components/ui/container.vue b/src/client/components/ui/container.vue deleted file mode 100644 index 14673dfcd7..0000000000 --- a/src/client/components/ui/container.vue +++ /dev/null @@ -1,262 +0,0 @@ -<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/src/client/components/ui/context-menu.vue b/src/client/components/ui/context-menu.vue deleted file mode 100644 index 61f5d3bf08..0000000000 --- a/src/client/components/ui/context-menu.vue +++ /dev/null @@ -1,97 +0,0 @@ -<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 '@client/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/src/client/components/ui/folder.vue b/src/client/components/ui/folder.vue deleted file mode 100644 index 3997421d08..0000000000 --- a/src/client/components/ui/folder.vue +++ /dev/null @@ -1,156 +0,0 @@ -<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/src/client/components/ui/hr.vue b/src/client/components/ui/hr.vue deleted file mode 100644 index fb12b4985f..0000000000 --- a/src/client/components/ui/hr.vue +++ /dev/null @@ -1,16 +0,0 @@ -<template> -<div class="evrzpitu"></div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue';import * as os from '@client/os'; - -export default defineComponent({}); -</script> - -<style lang="scss" scoped> -.evrzpitu - margin 16px 0 - border-bottom solid var(--lineWidth) var(--faceDivider) - -</style> diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue deleted file mode 100644 index f6b2edf267..0000000000 --- a/src/client/components/ui/info.vue +++ /dev/null @@ -1,45 +0,0 @@ -<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 '@client/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/src/client/components/ui/menu.vue b/src/client/components/ui/menu.vue deleted file mode 100644 index 5b3a0ae7c2..0000000000 --- a/src/client/components/ui/menu.vue +++ /dev/null @@ -1,278 +0,0 @@ -<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 '@client/scripts/focus'; -import contains from '@client/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/src/client/components/ui/modal-window.vue b/src/client/components/ui/modal-window.vue deleted file mode 100644 index da98192b87..0000000000 --- a/src/client/components/ui/modal-window.vue +++ /dev/null @@ -1,148 +0,0 @@ -<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/src/client/components/ui/modal.vue b/src/client/components/ui/modal.vue deleted file mode 100644 index 33fcdb687f..0000000000 --- a/src/client/components/ui/modal.vue +++ /dev/null @@ -1,292 +0,0 @@ -<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/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue deleted file mode 100644 index 1bd77447b7..0000000000 --- a/src/client/components/ui/pagination.vue +++ /dev/null @@ -1,69 +0,0 @@ -<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 '@client/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/src/client/components/ui/popup-menu.vue b/src/client/components/ui/popup-menu.vue deleted file mode 100644 index 3ff4c658b1..0000000000 --- a/src/client/components/ui/popup-menu.vue +++ /dev/null @@ -1,42 +0,0 @@ -<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/src/client/components/ui/popup.vue b/src/client/components/ui/popup.vue deleted file mode 100644 index 0fb1780cc5..0000000000 --- a/src/client/components/ui/popup.vue +++ /dev/null @@ -1,213 +0,0 @@ -<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/src/client/components/ui/super-menu.vue b/src/client/components/ui/super-menu.vue deleted file mode 100644 index 195cc57326..0000000000 --- a/src/client/components/ui/super-menu.vue +++ /dev/null @@ -1,148 +0,0 @@ -<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/src/client/components/ui/tooltip.vue b/src/client/components/ui/tooltip.vue deleted file mode 100644 index c003895c14..0000000000 --- a/src/client/components/ui/tooltip.vue +++ /dev/null @@ -1,92 +0,0 @@ -<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/src/client/components/ui/window.vue b/src/client/components/ui/window.vue deleted file mode 100644 index 00284b0467..0000000000 --- a/src/client/components/ui/window.vue +++ /dev/null @@ -1,525 +0,0 @@ -<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 '@client/scripts/contains'; -import * as os from '@client/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/src/client/components/updated.vue b/src/client/components/updated.vue deleted file mode 100644 index 9e5b24acdb..0000000000 --- a/src/client/components/updated.vue +++ /dev/null @@ -1,62 +0,0 @@ -<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 '@client/components/ui/modal.vue'; -import MkButton from '@client/components/ui/button.vue'; -import { version } from '@client/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/src/client/components/url-preview-popup.vue b/src/client/components/url-preview-popup.vue deleted file mode 100644 index b5e0fce207..0000000000 --- a/src/client/components/url-preview-popup.vue +++ /dev/null @@ -1,60 +0,0 @@ -<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 '@client/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/src/client/components/url-preview.vue b/src/client/components/url-preview.vue deleted file mode 100644 index 1d44b04578..0000000000 --- a/src/client/components/url-preview.vue +++ /dev/null @@ -1,334 +0,0 @@ -<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 '@client/config'; -import * as os from '@client/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/src/client/components/user-info.vue b/src/client/components/user-info.vue deleted file mode 100644 index e76f2ecaa6..0000000000 --- a/src/client/components/user-info.vue +++ /dev/null @@ -1,144 +0,0 @@ -<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 { parseAcct } from '@/misc/acct'; -import MkFollowButton from './follow-button.vue'; -import { userPage } from '@client/filters/user'; - -export default defineComponent({ - components: { - MkFollowButton - }, - - props: { - user: { - type: Object, - required: true - }, - }, - - data() { - return { - }; - }, - - methods: { - userPage, - parseAcct, - } -}); -</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/src/client/components/user-list.vue b/src/client/components/user-list.vue deleted file mode 100644 index 9c91183971..0000000000 --- a/src/client/components/user-list.vue +++ /dev/null @@ -1,91 +0,0 @@ -<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 '@client/scripts/paging'; -import MkUserInfo from './user-info.vue'; -import { userPage } from '@client/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/src/client/components/user-online-indicator.vue b/src/client/components/user-online-indicator.vue deleted file mode 100644 index afaf0e8736..0000000000 --- a/src/client/components/user-online-indicator.vue +++ /dev/null @@ -1,50 +0,0 @@ -<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/src/client/components/user-preview.vue b/src/client/components/user-preview.vue deleted file mode 100644 index 1249f205aa..0000000000 --- a/src/client/components/user-preview.vue +++ /dev/null @@ -1,192 +0,0 @@ -<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 { parseAcct } from '@/misc/acct'; -import MkFollowButton from './follow-button.vue'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/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('@') ? - parseAcct(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/src/client/components/user-select-dialog.vue b/src/client/components/user-select-dialog.vue deleted file mode 100644 index 0f3ee2a126..0000000000 --- a/src/client/components/user-select-dialog.vue +++ /dev/null @@ -1,199 +0,0 @@ -<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 '@client/components/ui/modal-window.vue'; -import * as os from '@client/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/src/client/components/users-dialog.vue b/src/client/components/users-dialog.vue deleted file mode 100644 index 5199f34c14..0000000000 --- a/src/client/components/users-dialog.vue +++ /dev/null @@ -1,147 +0,0 @@ -<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 '@client/scripts/paging'; -import { userPage } from '@client/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/src/client/components/visibility-picker.vue b/src/client/components/visibility-picker.vue deleted file mode 100644 index 492ec092e3..0000000000 --- a/src/client/components/visibility-picker.vue +++ /dev/null @@ -1,167 +0,0 @@ -<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 '@client/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/src/client/components/waiting-dialog.vue b/src/client/components/waiting-dialog.vue deleted file mode 100644 index ea9f6756b2..0000000000 --- a/src/client/components/waiting-dialog.vue +++ /dev/null @@ -1,92 +0,0 @@ -<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 '@client/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/src/client/components/widgets.vue b/src/client/components/widgets.vue deleted file mode 100644 index aef5de453c..0000000000 --- a/src/client/components/widgets.vue +++ /dev/null @@ -1,152 +0,0 @@ -<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 '@client/components/form/select.vue'; -import MkButton from '@client/components/ui/button.vue'; -import { widgets as widgetDefs } from '@client/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> |