diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /packages/client/src | |
| parent | update deps (diff) | |
| download | misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'packages/client/src')
531 files changed, 75230 insertions, 0 deletions
diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts new file mode 100644 index 0000000000..ef7eb8f60a --- /dev/null +++ b/packages/client/src/account.ts @@ -0,0 +1,211 @@ +import { del, get, set } from '@/scripts/idb-proxy'; +import { reactive } from 'vue'; +import { apiUrl } from '@/config'; +import { waiting, api, popup, popupMenu, success } from '@/os'; +import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { showSuspendedDialog } from './scripts/show-suspended-dialog'; +import { i18n } from './i18n'; + +// TODO: 他のタブと永続化されたstateを同期 + +type Account = { + id: string; + token: string; + isModerator: boolean; + isAdmin: boolean; + isDeleted: boolean; +}; + +const data = localStorage.getItem('account'); + +// TODO: 外部からはreadonlyに +export const $i = data ? reactive(JSON.parse(data) as Account) : null; + +export async function signout() { + waiting(); + localStorage.removeItem('account'); + + //#region Remove account + const accounts = await getAccounts(); + accounts.splice(accounts.findIndex(x => x.id === $i.id), 1); + + if (accounts.length > 0) await set('accounts', accounts); + else await del('accounts'); + //#endregion + + //#region Remove service worker registration + try { + if (navigator.serviceWorker.controller) { + const registration = await navigator.serviceWorker.ready; + const push = await registration.pushManager.getSubscription(); + if (push) { + await fetch(`${apiUrl}/sw/unregister`, { + method: 'POST', + body: JSON.stringify({ + i: $i.token, + endpoint: push.endpoint, + }), + }); + } + } + + if (accounts.length === 0) { + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }); + } + } catch (e) {} + //#endregion + + document.cookie = `igi=; path=/`; + + if (accounts.length > 0) login(accounts[0].token); + else unisonReload('/'); +} + +export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> { + return (await get('accounts')) || []; +} + +export async function addAccount(id: Account['id'], token: Account['token']) { + const accounts = await getAccounts(); + if (!accounts.some(x => x.id === id)) { + await set('accounts', accounts.concat([{ id, token }])); + } +} + +function fetchAccount(token): Promise<Account> { + return new Promise((done, fail) => { + // Fetch user + fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token + }) + }) + .then(res => res.json()) + .then(res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + showSuspendedDialog().then(() => { + signout(); + }); + } else { + signout(); + } + } else { + res.token = token; + done(res); + } + }) + .catch(fail); + }); +} + +export function updateAccount(data) { + for (const [key, value] of Object.entries(data)) { + $i[key] = value; + } + localStorage.setItem('account', JSON.stringify($i)); +} + +export function refreshAccount() { + return fetchAccount($i.token).then(updateAccount); +} + +export async function login(token: Account['token'], redirect?: string) { + waiting(); + if (_DEV_) console.log('logging as token ', token); + const me = await fetchAccount(token); + localStorage.setItem('account', JSON.stringify(me)); + await addAccount(me.id, token); + + if (redirect) { + // 他のタブは再読み込みするだけ + reloadChannel.postMessage(null); + // このページはredirectで指定された先に移動 + location.href = redirect; + return; + } + + unisonReload(); +} + +export async function openAccountMenu(ev: MouseEvent) { + function showSigninDialog() { + popup(import('@/components/signin-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + success(); + }, + }, 'closed'); + } + + function createAccount() { + popup(import('@/components/signup-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + switchAccountWithToken(res.i); + }, + }, 'closed'); + } + + async function switchAccount(account: any) { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + switchAccountWithToken(token); + } + + function switchAccountWithToken(token: string) { + login(token); + } + + const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); + const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); + + const accountItemPromises = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res({ + type: 'user', + user: account, + action: () => { switchAccount(account); } + }); + }); + })); + + popupMenu([...[{ + type: 'link', + text: i18n.locale.profile, + to: `/@${ $i.username }`, + avatar: $i, + }, null, ...accountItemPromises, { + icon: 'fas fa-plus', + text: i18n.locale.addAccount, + action: () => { + popupMenu([{ + text: i18n.locale.existingAccount, + action: () => { showSigninDialog(); }, + }, { + text: i18n.locale.createAccount, + action: () => { createAccount(); }, + }], ev.currentTarget || ev.target); + }, + }, { + type: 'link', + icon: 'fas fa-users', + text: i18n.locale.manageAccounts, + to: `/settings/accounts`, + }]], ev.currentTarget || ev.target, { + align: 'left' + }); +} + +// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $i: typeof $i; + } +} diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue new file mode 100644 index 0000000000..700ce30bb2 --- /dev/null +++ b/packages/client/src/components/abuse-report-window.vue @@ -0,0 +1,79 @@ +<template> +<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')"> + <template #header> + <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i> + <I18n :src="$ts.reportAbuseOf" tag="span"> + <template #name> + <b><MkAcct :user="user"/></b> + </template> + </I18n> + </template> + <div class="dpvffvvy _monolithic_"> + <div class="_section"> + <MkTextarea v-model="comment"> + <template #label>{{ $ts.details }}</template> + <template #caption>{{ $ts.fillAbuseReportDescription }}</template> + </MkTextarea> + </div> + <div class="_section"> + <MkButton @click="send" primary full :disabled="comment.length === 0">{{ $ts.send }}</MkButton> + </div> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import XWindow from '@/components/ui/window.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XWindow, + MkTextarea, + MkButton, + }, + + props: { + user: { + type: Object, + required: true, + }, + initialComment: { + type: String, + required: false, + }, + }, + + emits: ['closed'], + + data() { + return { + comment: this.initialComment || '', + }; + }, + + methods: { + send() { + os.apiWithDialog('users/report-abuse', { + userId: this.user.id, + comment: this.comment, + }, undefined, res => { + os.dialog({ + type: 'success', + text: this.$ts.abuseReported + }); + this.$refs.window.close(); + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.dpvffvvy { + --root-margin: 16px; +} +</style> diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue new file mode 100644 index 0000000000..bc572e5fff --- /dev/null +++ b/packages/client/src/components/analog-clock.vue @@ -0,0 +1,150 @@ +<template> +<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none"> + <circle v-for="(angle, i) in graduations" + :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))" + :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))" + :r="i % 5 == 0 ? 0.125 : 0.05" + :fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor" + :key="i" + /> + + <line + :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" + :stroke="sHandColor" + :stroke-width="thickness / 2" + stroke-linecap="round" + /> + + <line + :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" + :stroke="mHandColor" + :stroke-width="thickness" + stroke-linecap="round" + /> + + <line + :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))" + :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))" + :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" + :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" + :stroke="hHandColor" + :stroke-width="thickness" + stroke-linecap="round" + /> +</svg> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; + +export default defineComponent({ + props: { + thickness: { + type: Number, + default: 0.1 + } + }, + + data() { + return { + now: new Date(), + enabled: true, + + graduationsPadding: 0.5, + handsPadding: 1, + handsTailLength: 0.7, + hHandLengthRatio: 0.75, + mHandLengthRatio: 1, + sHandLengthRatio: 1, + + computedStyle: getComputedStyle(document.documentElement) + }; + }, + + computed: { + dark(): boolean { + return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark(); + }, + + majorGraduationColor(): string { + return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; + }, + minorGraduationColor(): string { + return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + }, + + sHandColor(): string { + return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; + }, + mHandColor(): string { + return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString(); + }, + hHandColor(): string { + return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString(); + }, + + s(): number { + return this.now.getSeconds(); + }, + m(): number { + return this.now.getMinutes(); + }, + h(): number { + return this.now.getHours(); + }, + + hAngle(): number { + return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6; + }, + mAngle(): number { + return Math.PI * (this.m + this.s / 60) / 30; + }, + sAngle(): number { + return Math.PI * this.s / 30; + }, + + graduations(): any { + const angles = []; + for (let i = 0; i < 60; i++) { + const angle = Math.PI * i / 30; + angles.push(angle); + } + + return angles; + } + }, + + mounted() { + const update = () => { + if (this.enabled) { + this.tick(); + setTimeout(update, 1000); + } + }; + update(); + }, + + beforeUnmount() { + this.enabled = false; + }, + + methods: { + tick() { + this.now = new Date(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mbcofsoe { + display: block; +} +</style> diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue new file mode 100644 index 0000000000..a7d2d507e0 --- /dev/null +++ b/packages/client/src/components/autocomplete.vue @@ -0,0 +1,501 @@ +<template> +<div class="swhvrteh _popup _shadow" @contextmenu.prevent="() => {}"> + <ol class="users" ref="suggests" v-if="type === 'user'"> + <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user"> + <img class="avatar" :src="user.avatarUrl"/> + <span class="name"> + <MkUserName :user="user" :key="user.id"/> + </span> + <span class="username">@{{ acct(user) }}</span> + </li> + <li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $ts.selectUser }}</li> + </ol> + <ol class="hashtags" ref="suggests" v-else-if="hashtags.length > 0"> + <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1"> + <span class="name">{{ hashtag }}</span> + </li> + </ol> + <ol class="emojis" ref="suggests" v-else-if="emojis.length > 0"> + <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> + <span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> + <span class="emoji" v-else-if="!$store.state.useOsNativeEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span> + <span class="emoji" v-else>{{ emoji.emoji }}</span> + <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> + <span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span> + </li> + </ol> + <ol class="mfmTags" ref="suggests" v-else-if="mfmTags.length > 0"> + <li v-for="tag in mfmTags" @click="complete(type, tag)" @keydown="onKeydown" tabindex="-1"> + <span class="tag">{{ tag }}</span> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { emojilist } from '@/scripts/emojilist'; +import contains from '@/scripts/contains'; +import { twemojiSvgBase } from '@/scripts/twemoji-base'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import { instance } from '@/instance'; +import { MFM_TAGS } from '@/scripts/mfm-tags'; + +type EmojiDef = { + emoji: string; + name: string; + aliasOf?: string; + url?: string; + isCustomEmoji?: boolean; +}; + +const lib = emojilist.filter(x => x.category !== 'flags'); + +const char2file = (char: string) => { + let codes = Array.from(char).map(x => x.codePointAt(0).toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); + return codes.join('-'); +}; + +const emjdb: EmojiDef[] = lib.map(x => ({ + emoji: x.char, + name: x.name, + aliasOf: null, + url: `${twemojiSvgBase}/${char2file(x.char)}.svg` +})); + +for (const x of lib) { + if (x.keywords) { + for (const k of x.keywords) { + emjdb.push({ + emoji: x.char, + name: k, + aliasOf: x.name, + url: `${twemojiSvgBase}/${char2file(x.char)}.svg` + }); + } + } +} + +emjdb.sort((a, b) => a.name.length - b.name.length); + +//#region Construct Emoji DB +const customEmojis = instance.emojis; +const emojiDefinitions: EmojiDef[] = []; + +for (const x of customEmojis) { + emojiDefinitions.push({ + name: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true + }); + + if (x.aliases) { + for (const alias of x.aliases) { + emojiDefinitions.push({ + name: alias, + aliasOf: x.name, + emoji: `:${x.name}:`, + url: x.url, + isCustomEmoji: true + }); + } + } +} + +emojiDefinitions.sort((a, b) => a.name.length - b.name.length); + +const emojiDb = markRaw(emojiDefinitions.concat(emjdb)); +//#endregion + +export default defineComponent({ + props: { + type: { + type: String, + required: true, + }, + + q: { + type: String, + required: false, + }, + + textarea: { + type: HTMLTextAreaElement, + required: true, + }, + + close: { + type: Function, + required: true, + }, + + x: { + type: Number, + required: true, + }, + + y: { + type: Number, + required: true, + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + getStaticImageUrl, + fetching: true, + users: [], + hashtags: [], + emojis: [], + items: [], + mfmTags: [], + select: -1, + } + }, + + updated() { + this.setPosition(); + this.items = (this.$refs.suggests as Element | undefined)?.children || []; + }, + + mounted() { + this.setPosition(); + + this.textarea.addEventListener('keydown', this.onKeydown); + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', this.onMousedown); + } + + this.$nextTick(() => { + this.exec(); + + this.$watch('q', () => { + this.$nextTick(() => { + this.exec(); + }); + }); + }); + }, + + beforeUnmount() { + this.textarea.removeEventListener('keydown', this.onKeydown); + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + + methods: { + complete(type, value) { + this.$emit('done', { type, value }); + this.$emit('closed'); + + if (type === 'emoji') { + let recents = this.$store.state.recentlyUsedEmojis; + recents = recents.filter((e: any) => e !== value); + recents.unshift(value); + this.$store.set('recentlyUsedEmojis', recents.splice(0, 32)); + } + }, + + setPosition() { + if (this.x + this.$el.offsetWidth > window.innerWidth) { + this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px'; + } else { + this.$el.style.left = this.x + 'px'; + } + + if (this.y + this.$el.offsetHeight > window.innerHeight) { + this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px'; + this.$el.style.marginTop = '0'; + } else { + this.$el.style.top = this.y + 'px'; + this.$el.style.marginTop = 'calc(1em + 8px)'; + } + }, + + exec() { + this.select = -1; + if (this.$refs.suggests) { + for (const el of Array.from(this.items)) { + el.removeAttribute('data-selected'); + } + } + + if (this.type === 'user') { + if (this.q == null) { + this.users = []; + this.fetching = false; + return; + } + + const cacheKey = `autocomplete:user:${this.q}`; + const cache = sessionStorage.getItem(cacheKey); + if (cache) { + const users = JSON.parse(cache); + this.users = users; + this.fetching = false; + } else { + os.api('users/search-by-username-and-host', { + username: this.q, + limit: 10, + detail: false + }).then(users => { + this.users = users; + this.fetching = false; + + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(users)); + }); + } + } else if (this.type === 'hashtag') { + if (this.q == null || this.q == '') { + this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); + this.fetching = false; + } else { + const cacheKey = `autocomplete:hashtag:${this.q}`; + const cache = sessionStorage.getItem(cacheKey); + if (cache) { + const hashtags = JSON.parse(cache); + this.hashtags = hashtags; + this.fetching = false; + } else { + os.api('hashtags/search', { + query: this.q, + limit: 30 + }).then(hashtags => { + this.hashtags = hashtags; + this.fetching = false; + + // キャッシュ + sessionStorage.setItem(cacheKey, JSON.stringify(hashtags)); + }); + } + } + } else if (this.type === 'emoji') { + if (this.q == null || this.q == '') { + // 最近使った絵文字をサジェスト + this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null); + return; + } + + const matched = []; + const max = 30; + + emojiDb.some(x => { + if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + if (matched.length < max) { + emojiDb.some(x => { + if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + } + if (matched.length < max) { + emojiDb.some(x => { + if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); + return matched.length == max; + }); + } + + this.emojis = matched; + } else if (this.type === 'mfmTag') { + if (this.q == null || this.q == '') { + this.mfmTags = MFM_TAGS; + return; + } + + this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q)); + } + }, + + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + + onKeydown(e) { + const cancel = () => { + e.preventDefault(); + e.stopPropagation(); + }; + + switch (e.which) { + case 10: // [ENTER] + case 13: // [ENTER] + if (this.select !== -1) { + cancel(); + (this.items[this.select] as any).click(); + } else { + this.close(); + } + break; + + case 27: // [ESC] + cancel(); + this.close(); + break; + + case 38: // [↑] + if (this.select !== -1) { + cancel(); + this.selectPrev(); + } else { + this.close(); + } + break; + + case 9: // [TAB] + case 40: // [↓] + cancel(); + this.selectNext(); + break; + + default: + e.stopPropagation(); + this.textarea.focus(); + } + }, + + selectNext() { + if (++this.select >= this.items.length) this.select = 0; + if (this.items.length === 0) this.select = -1; + this.applySelect(); + }, + + selectPrev() { + if (--this.select < 0) this.select = this.items.length - 1; + this.applySelect(); + }, + + applySelect() { + for (const el of Array.from(this.items)) { + el.removeAttribute('data-selected'); + } + + if (this.select !== -1) { + this.items[this.select].setAttribute('data-selected', 'true'); + (this.items[this.select] as any).focus(); + } + }, + + chooseUser() { + this.close(); + os.selectUser().then(user => { + this.complete('user', user); + this.textarea.focus(); + }); + }, + + acct + } +}); +</script> + +<style lang="scss" scoped> +.swhvrteh { + position: fixed; + z-index: 65535; + max-width: 100%; + margin-top: calc(1em + 8px); + overflow: hidden; + transition: top 0.1s ease, left 0.1s ease; + + > ol { + display: block; + margin: 0; + padding: 4px 0; + max-height: 190px; + max-width: 500px; + overflow: auto; + list-style: none; + + > li { + display: flex; + align-items: center; + padding: 4px 12px; + white-space: nowrap; + overflow: hidden; + font-size: 0.9em; + cursor: default; + + &, * { + user-select: none; + } + + * { + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + background: var(--X3); + } + + &[data-selected='true'] { + background: var(--accent); + + &, * { + color: #fff !important; + } + } + + &:active { + background: var(--accentDarken); + + &, * { + color: #fff !important; + } + } + } + } + + > .users > li { + + .avatar { + min-width: 28px; + min-height: 28px; + max-width: 28px; + max-height: 28px; + margin: 0 8px 0 0; + border-radius: 100%; + } + + .name { + margin: 0 8px 0 0; + } + } + + > .emojis > li { + + .emoji { + display: inline-block; + margin: 0 4px 0 0; + width: 24px; + + > img { + width: 24px; + vertical-align: bottom; + } + } + + .alias { + margin: 0 0 0 8px; + } + } + + > .mfmTags > li { + + .name { + } + } +} +</style> diff --git a/packages/client/src/components/avatars.vue b/packages/client/src/components/avatars.vue new file mode 100644 index 0000000000..e843d26daa --- /dev/null +++ b/packages/client/src/components/avatars.vue @@ -0,0 +1,30 @@ +<template> +<div> + <div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> + <MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + userIds: { + required: true + }, + }, + data() { + return { + us: [] + }; + }, + async created() { + this.us = await os.api('users/show', { + userIds: this.userIds + }); + } +}); +</script> diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue new file mode 100644 index 0000000000..baa922506e --- /dev/null +++ b/packages/client/src/components/captcha.vue @@ -0,0 +1,123 @@ +<template> +<div> + <span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span> + <div ref="captcha"></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; + +type Captcha = { + render(container: string | Node, options: { + readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; + }): string; + remove(id: string): void; + execute(id: string): void; + reset(id: string): void; + getResponse(id: string): string; +}; + +type CaptchaProvider = 'hcaptcha' | 'recaptcha'; + +type CaptchaContainer = { + readonly [_ in CaptchaProvider]?: Captcha; +}; + +declare global { + interface Window extends CaptchaContainer { + } +} + +export default defineComponent({ + props: { + provider: { + type: String as PropType<CaptchaProvider>, + required: true, + }, + sitekey: { + type: String, + required: true, + }, + modelValue: { + type: String, + }, + }, + + data() { + return { + available: false, + }; + }, + + computed: { + variable(): string { + switch (this.provider) { + case 'hcaptcha': return 'hcaptcha'; + case 'recaptcha': return 'grecaptcha'; + } + }, + loaded(): boolean { + return !!window[this.variable]; + }, + src(): string { + const endpoint = ({ + hcaptcha: 'https://hcaptcha.com/1', + recaptcha: 'https://www.recaptcha.net/recaptcha', + } as Record<CaptchaProvider, string>)[this.provider]; + + return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; + }, + captcha(): Captcha { + return window[this.variable] || {} as unknown as Captcha; + }, + }, + + created() { + if (this.loaded) { + this.available = true; + } else { + (document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { + async: true, + id: this.provider, + src: this.src, + }))) + .addEventListener('load', () => this.available = true); + } + }, + + mounted() { + if (this.available) { + this.requestRender(); + } else { + this.$watch('available', this.requestRender); + } + }, + + beforeUnmount() { + this.reset(); + }, + + methods: { + reset() { + if (this.captcha?.reset) this.captcha.reset(); + }, + requestRender() { + if (this.captcha.render && this.$refs.captcha instanceof Element) { + this.captcha.render(this.$refs.captcha, { + sitekey: this.sitekey, + theme: this.$store.state.darkMode ? 'dark' : 'light', + callback: this.callback, + 'expired-callback': this.callback, + 'error-callback': this.callback, + }); + } else { + setTimeout(this.requestRender.bind(this), 1); + } + }, + callback(response?: string) { + this.$emit('update:modelValue', typeof response == 'string' ? response : null); + }, + }, +}); +</script> diff --git a/packages/client/src/components/channel-follow-button.vue b/packages/client/src/components/channel-follow-button.vue new file mode 100644 index 0000000000..9af65325bb --- /dev/null +++ b/packages/client/src/components/channel-follow-button.vue @@ -0,0 +1,140 @@ +<template> +<button class="hdcaacmi _button" + :class="{ wait, active: isFollowing, full }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait"> + <template v-if="isFollowing"> + <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i> + </template> + <template v-else> + <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i> + </template> + </template> + <template v-else> + <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> + </template> +</button> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + channel: { + type: Object, + required: true + }, + full: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isFollowing: this.channel.isFollowing, + wait: false, + }; + }, + + methods: { + async onClick() { + this.wait = true; + + try { + if (this.isFollowing) { + await os.api('channels/unfollow', { + channelId: this.channel.id + }); + this.isFollowing = false; + } else { + await os.api('channels/follow', { + channelId: this.channel.id + }); + this.isFollowing = true; + } + } catch (e) { + console.error(e); + } finally { + this.wait = false; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.hdcaacmi { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + height: 31px; + font-size: 16px; + border-radius: 32px; + background: #fff; + + &.full { + padding: 0 8px 0 12px; + font-size: 14px; + } + + &:not(.full) { + width: 31px; + } + + &:focus-visible { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 32px; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } + + > span { + margin-right: 6px; + } +} +</style> diff --git a/packages/client/src/components/channel-preview.vue b/packages/client/src/components/channel-preview.vue new file mode 100644 index 0000000000..eb00052a78 --- /dev/null +++ b/packages/client/src/components/channel-preview.vue @@ -0,0 +1,165 @@ +<template> +<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> + <div class="banner" :style="bannerStyle"> + <div class="fade"></div> + <div class="name"><i class="fas fa-satellite-dish"></i> {{ channel.name }}</div> + <div class="status"> + <div> + <i class="fas fa-users fa-fw"></i> + <I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.usersCount }}</b> + </template> + </I18n> + </div> + <div> + <i class="fas fa-pencil-alt fa-fw"></i> + <I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.notesCount }}</b> + </template> + </I18n> + </div> + </div> + </div> + <article v-if="channel.description"> + <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> + </article> + <footer> + <span v-if="channel.lastNotedAt"> + {{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> + </span> + </footer> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + channel: { + type: Object, + required: true + }, + }, + + computed: { + bannerStyle() { + if (this.channel.bannerUrl) { + return { backgroundImage: `url(${this.channel.bannerUrl})` }; + } else { + return { backgroundColor: '#4c5e6d' }; + } + } + }, + + data() { + return { + }; + }, +}); +</script> + +<style lang="scss" scoped> +.eftoefju { + display: block; + overflow: hidden; + width: 100%; + + &:hover { + text-decoration: none; + } + + > .banner { + position: relative; + width: 100%; + height: 200px; + background-position: center; + background-size: cover; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + + > .name { + position: absolute; + top: 16px; + left: 16px; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 1.2em; + } + + > .status { + position: absolute; + z-index: 1; + bottom: 16px; + right: 16px; + padding: 8px 12px; + font-size: 80%; + background: rgba(0, 0, 0, 0.7); + border-radius: 6px; + color: #fff; + } + } + + > article { + padding: 16px; + + > p { + margin: 0; + font-size: 1em; + } + } + + > footer { + padding: 12px 16px; + border-top: solid 0.5px var(--divider); + + > span { + opacity: 0.7; + font-size: 0.9em; + } + } + + @media (max-width: 550px) { + font-size: 0.9em; + + > .banner { + height: 80px; + + > .status { + display: none; + } + } + + > article { + padding: 12px; + } + + > footer { + display: none; + } + } + + @media (max-width: 500px) { + font-size: 0.8em; + + > .banner { + height: 70px; + } + + > article { + padding: 8px; + } + } +} + +</style> diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue new file mode 100644 index 0000000000..c4d0eb85dd --- /dev/null +++ b/packages/client/src/components/chart.vue @@ -0,0 +1,691 @@ +<template> +<div class="cbbedffa"> + <canvas ref="chartEl"></canvas> + <div v-if="fetching" class="fetching"> + <MkLoading/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref, watch, PropType } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import { enUS } from 'date-fns/locale'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + zoomPlugin, +); + +const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); +const negate = arr => arr.map(x => -x); +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560']; +const getColor = (i) => { + return colors[i % colors.length]; +}; + +export default defineComponent({ + props: { + src: { + type: String, + required: true, + }, + args: { + type: Object, + required: false, + }, + limit: { + type: Number, + required: false, + default: 90 + }, + span: { + type: String as PropType<'hour' | 'day'>, + required: true, + }, + detailed: { + type: Boolean, + required: false, + default: false + }, + stacked: { + type: Boolean, + required: false, + default: false + }, + aspectRatio: { + type: Number, + required: false, + default: null + }, + }, + + setup(props) { + const now = new Date(); + let chartInstance: Chart = null; + let data: { + series: { + name: string; + type: 'line' | 'area'; + color?: string; + borderDash?: number[]; + hidden?: boolean; + data: { + x: number; + y: number; + }[]; + }[]; + } = null; + + const chartEl = ref<HTMLCanvasElement>(null); + const fetching = ref(true); + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + const h = now.getHours(); + + return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v + })); + }; + + const render = () => { + if (chartInstance) { + chartInstance.destroy(); + } + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), + datasets: data.series.map((x, i) => ({ + parsing: false, + label: x.name, + data: x.data.slice().reverse(), + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: x.color ? x.color : getColor(i), + borderDash: x.borderDash || [], + borderJoinStyle: 'round', + backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1), + fill: x.type === 'area', + hidden: !!x.hidden, + })), + }, + options: { + aspectRatio: props.aspectRatio || 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 8, + }, + }, + scales: { + x: { + type: 'time', + time: { + stepSize: 1, + unit: props.span === 'day' ? 'month' : 'day', + }, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(props.limit).getTime(), + }, + y: { + position: 'left', + stacked: props.stacked, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: props.detailed, + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: props.detailed, + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + tooltip: { + mode: 'index', + animation: { + duration: 0, + }, + }, + zoom: { + pan: { + enabled: true, + }, + zoom: { + wheel: { + enabled: true, + }, + pinch: { + enabled: true, + }, + drag: { + enabled: false, + }, + mode: 'x', + }, + limits: { + x: { + min: 'original', + max: 'original', + }, + y: { + min: 'original', + max: 'original', + }, + } + }, + }, + }, + }); + }; + + const exportData = () => { + // TODO + }; + + const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Instances', + type: 'area', + data: format(total + ? raw.instance.total + : sum(raw.instance.inc, negate(raw.instance.dec)) + ), + }], + }; + }; + + const fetchNotesChart = async (type: string): Promise<typeof data> => { + const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format(type == 'combined' + ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + : sum(raw[type].inc, negate(raw[type].dec)) + ), + }, { + name: 'Renotes', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) + : raw[type].diffs.renote + ), + }, { + name: 'Replies', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) + : raw[type].diffs.reply + ), + }, { + name: 'Normal', + type: 'area', + data: format(type == 'combined' + ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) + : raw[type].diffs.normal + ), + }], + }; + }; + + const fetchNotesTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.total, raw.remote.total)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.total), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.total), + }], + }; + }; + + const fetchUsersChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(total + ? sum(raw.local.total, raw.remote.total) + : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + ), + }, { + name: 'Local', + type: 'area', + data: format(total + ? raw.local.total + : sum(raw.local.inc, negate(raw.local.dec)) + ), + }, { + name: 'Remote', + type: 'area', + data: format(total + ? raw.remote.total + : sum(raw.remote.inc, negate(raw.remote.dec)) + ), + }], + }; + }; + + const fetchActiveUsersChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.users, raw.remote.users)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.users), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.users), + }], + }; + }; + + const fetchDriveChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format( + sum( + raw.local.incSize, + negate(raw.local.decSize), + raw.remote.incSize, + negate(raw.remote.decSize) + ) + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incSize), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decSize)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incSize), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decSize)), + }], + }; + }; + + const fetchDriveTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.totalSize, raw.remote.totalSize)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.totalSize), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.totalSize), + }], + }; + }; + + const fetchDriveFilesChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format( + sum( + raw.local.incCount, + negate(raw.local.decCount), + raw.remote.incCount, + negate(raw.remote.decCount) + ) + ), + }, { + name: 'Local +', + type: 'area', + data: format(raw.local.incCount), + }, { + name: 'Local -', + type: 'area', + data: format(negate(raw.local.decCount)), + }, { + name: 'Remote +', + type: 'area', + data: format(raw.remote.incCount), + }, { + name: 'Remote -', + type: 'area', + data: format(negate(raw.remote.decCount)), + }], + }; + }; + + const fetchDriveFilesTotalChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Combined', + type: 'line', + data: format(sum(raw.local.totalCount, raw.remote.totalCount)), + }, { + name: 'Local', + type: 'area', + data: format(raw.local.totalCount), + }, { + name: 'Remote', + type: 'area', + data: format(raw.remote.totalCount), + }], + }; + }; + + const fetchInstanceRequestsChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'In', + type: 'area', + color: '#008FFB', + data: format(raw.requests.received) + }, { + name: 'Out (succ)', + type: 'area', + color: '#00E396', + data: format(raw.requests.succeeded) + }, { + name: 'Out (fail)', + type: 'area', + color: '#FEB019', + data: format(raw.requests.failed) + }] + }; + }; + + const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Users', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.users.total + : sum(raw.users.inc, negate(raw.users.dec)) + ) + }] + }; + }; + + const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Notes', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.notes.total + : sum(raw.notes.inc, negate(raw.notes.dec)) + ) + }] + }; + }; + + const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Following', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.following.total + : sum(raw.following.inc, negate(raw.following.dec)) + ) + }, { + name: 'Followers', + type: 'area', + color: '#00E396', + data: format(total + ? raw.followers.total + : sum(raw.followers.inc, negate(raw.followers.dec)) + ) + }] + }; + }; + + const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + bytes: true, + series: [{ + name: 'Drive usage', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalUsage + : sum(raw.drive.incUsage, negate(raw.drive.decUsage)) + ) + }] + }; + }; + + const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => { + const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + return { + series: [{ + name: 'Drive files', + type: 'area', + color: '#008FFB', + data: format(total + ? raw.drive.totalFiles + : sum(raw.drive.incFiles, negate(raw.drive.decFiles)) + ) + }] + }; + }; + + const fetchPerUserNotesChart = async (): Promise<typeof data> => { + const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + return { + series: [...(props.args.withoutAll ? [] : [{ + name: 'All', + type: 'line', + borderDash: [5, 5], + data: format(sum(raw.inc, negate(raw.dec))), + }]), { + name: 'Renotes', + type: 'area', + data: format(raw.diffs.renote), + }, { + name: 'Replies', + type: 'area', + data: format(raw.diffs.reply), + }, { + name: 'Normal', + type: 'area', + data: format(raw.diffs.normal), + }], + }; + }; + + const fetchAndRender = async () => { + const fetchData = () => { + switch (props.src) { + case 'federation-instances': return fetchFederationInstancesChart(false); + case 'federation-instances-total': return fetchFederationInstancesChart(true); + case 'users': return fetchUsersChart(false); + case 'users-total': return fetchUsersChart(true); + case 'active-users': return fetchActiveUsersChart(); + case 'notes': return fetchNotesChart('combined'); + case 'local-notes': return fetchNotesChart('local'); + case 'remote-notes': return fetchNotesChart('remote'); + case 'notes-total': return fetchNotesTotalChart(); + case 'drive': return fetchDriveChart(); + case 'drive-total': return fetchDriveTotalChart(); + case 'drive-files': return fetchDriveFilesChart(); + case 'drive-files-total': return fetchDriveFilesTotalChart(); + + case 'instance-requests': return fetchInstanceRequestsChart(); + case 'instance-users': return fetchInstanceUsersChart(false); + case 'instance-users-total': return fetchInstanceUsersChart(true); + case 'instance-notes': return fetchInstanceNotesChart(false); + case 'instance-notes-total': return fetchInstanceNotesChart(true); + case 'instance-ff': return fetchInstanceFfChart(false); + case 'instance-ff-total': return fetchInstanceFfChart(true); + case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false); + case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true); + case 'instance-drive-files': return fetchInstanceDriveFilesChart(false); + case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true); + + case 'per-user-notes': return fetchPerUserNotesChart(); + } + }; + fetching.value = true; + data = await fetchData(); + fetching.value = false; + render(); + }; + + watch(() => [props.src, props.span], fetchAndRender); + + onMounted(() => { + fetchAndRender(); + }); + + return { + chartEl, + fetching, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.cbbedffa { + position: relative; + + > .fetching { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--blur, blur(12px)); + backdrop-filter: var(--blur, blur(12px)); + display: flex; + justify-content: center; + align-items: center; + cursor: wait; + } +} +</style> diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue new file mode 100644 index 0000000000..9cff7b4448 --- /dev/null +++ b/packages/client/src/components/code-core.vue @@ -0,0 +1,35 @@ +<template> +<code v-if="inline" v-html="html" :class="`language-${prismLang}`"></code> +<pre v-else :class="`language-${prismLang}`"><code v-html="html" :class="`language-${prismLang}`"></code></pre> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import 'prismjs'; +import 'prismjs/themes/prism-okaidia.css'; + +export default defineComponent({ + props: { + code: { + type: String, + required: true + }, + lang: { + type: String, + required: false + }, + inline: { + type: Boolean, + required: false + } + }, + computed: { + prismLang() { + return Prism.languages[this.lang] ? this.lang : 'js'; + }, + html() { + return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang); + } + } +}); +</script> diff --git a/packages/client/src/components/code.vue b/packages/client/src/components/code.vue new file mode 100644 index 0000000000..f5d6c5673a --- /dev/null +++ b/packages/client/src/components/code.vue @@ -0,0 +1,27 @@ +<template> +<XCode :code="code" :lang="lang" :inline="inline"/> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; + +export default defineComponent({ + components: { + XCode: defineAsyncComponent(() => import('./code-core.vue')) + }, + props: { + code: { + type: String, + required: true + }, + lang: { + type: String, + required: false + }, + inline: { + type: Boolean, + required: false + } + } +}); +</script> diff --git a/packages/client/src/components/cw-button.vue b/packages/client/src/components/cw-button.vue new file mode 100644 index 0000000000..4bec7b511e --- /dev/null +++ b/packages/client/src/components/cw-button.vue @@ -0,0 +1,70 @@ +<template> +<button class="nrvgflfu _button" @click="toggle"> + <b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b> + <span v-if="!modelValue">{{ label }}</span> +</button> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { length } from 'stringz'; +import { concat } from '@/scripts/array'; + +export default defineComponent({ + props: { + modelValue: { + type: Boolean, + required: true + }, + note: { + type: Object, + required: true + } + }, + + computed: { + label(): string { + return concat([ + this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [], + this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [], + this.note.poll != null ? [this.$ts.poll] : [] + ] as string[][]).join(' / '); + } + }, + + methods: { + length, + + toggle() { + this.$emit('update:modelValue', !this.modelValue); + } + } +}); +</script> + +<style lang="scss" scoped> +.nrvgflfu { + display: inline-block; + padding: 4px 8px; + font-size: 0.7em; + color: var(--cwFg); + background: var(--cwBg); + border-radius: 2px; + + &:hover { + background: var(--cwHoverBg); + } + + > span { + margin-left: 4px; + + &:before { + content: '('; + } + + &:after { + content: ')'; + } + } +} +</style> diff --git a/packages/client/src/components/date-separated-list.vue b/packages/client/src/components/date-separated-list.vue new file mode 100644 index 0000000000..1aea9fd353 --- /dev/null +++ b/packages/client/src/components/date-separated-list.vue @@ -0,0 +1,188 @@ +<script lang="ts"> +import { defineComponent, h, PropType, TransitionGroup } from 'vue'; +import MkAd from '@/components/global/ad.vue'; + +export default defineComponent({ + props: { + items: { + type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, + required: true, + }, + direction: { + type: String, + required: false, + default: 'down' + }, + reversed: { + type: Boolean, + required: false, + default: false + }, + noGap: { + type: Boolean, + required: false, + default: false + }, + ad: { + type: Boolean, + required: false, + default: false + }, + }, + + methods: { + focus() { + this.$slots.default[0].elm.focus(); + }, + + getDateText(time: string) { + const date = new Date(time).getDate(); + const month = new Date(time).getMonth() + 1; + return this.$t('monthAndDay', { + month: month.toString(), + day: date.toString() + }); + } + }, + + render() { + if (this.items.length === 0) return; + + const renderChildren = () => this.items.map((item, i) => { + const el = this.$slots.default({ + item: item + })[0]; + if (el.key == null && item.id) el.key = item.id; + + if ( + i != this.items.length - 1 && + new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() + ) { + const separator = h('div', { + class: 'separator', + key: item.id + ':separator', + }, h('p', { + class: 'date' + }, [ + h('span', [ + h('i', { + class: 'fas fa-angle-up icon', + }), + this.getDateText(item.createdAt) + ]), + h('span', [ + this.getDateText(this.items[i + 1].createdAt), + h('i', { + class: 'fas fa-angle-down icon', + }) + ]) + ])); + + return [el, separator]; + } else { + if (this.ad && item._shouldInsertAd_) { + return [h(MkAd, { + class: 'a', // advertiseの意(ブロッカー対策) + key: item.id + ':ad', + prefer: ['horizontal', 'horizontal-big'], + }), el]; + } else { + return el; + } + } + }); + + return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? { + class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''), + name: 'list', + tag: 'div', + 'data-direction': this.direction, + 'data-reversed': this.reversed ? 'true' : 'false', + } : { + class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''), + }, { + default: renderChildren + }); + }, +}); +</script> + +<style lang="scss"> +.sqadhkmv { + > *:empty { + display: none; + } + + > *:not(:last-child) { + margin-bottom: var(--margin); + } + + > .list-move { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + > .list-enter-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + + &[data-direction="up"] { + > .list-enter-from { + opacity: 0; + transform: translateY(64px); + } + } + + &[data-direction="down"] { + > .list-enter-from { + opacity: 0; + transform: translateY(-64px); + } + } + + > .separator { + text-align: center; + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } + + &.noGap { + > * { + margin: 0 !important; + border: none; + border-radius: 0; + box-shadow: none; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/base.vue b/packages/client/src/components/debobigego/base.vue new file mode 100644 index 0000000000..f551a3478b --- /dev/null +++ b/packages/client/src/components/debobigego/base.vue @@ -0,0 +1,65 @@ +<template> +<div class="rbusrurv" :class="{ wide: forceWide }" v-size="{ max: [400] }"> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + forceWide: { + type: Boolean, + required: false, + default: false, + } + } +}); +</script> + +<style lang="scss" scoped> +.rbusrurv { + // 他のCSSからも参照されるので消さないように + --debobigegoXPadding: 32px; + --debobigegoYPadding: 32px; + + --debobigegoContentHMargin: 16px; + + font-size: 95%; + line-height: 1.3em; + background: var(--bg); + padding: var(--debobigegoYPadding) var(--debobigegoXPadding); + max-width: 750px; + margin: 0 auto; + + &:not(.wide).max-width_400px { + --debobigegoXPadding: 0px; + + > ::v-deep(*) { + ._debobigegoPanel { + border: solid 0.5px var(--divider); + border-radius: 0; + border-left: none; + border-right: none; + } + + ._debobigego_group { + > *:not(._debobigegoNoConcat) { + &:not(:last-child):not(._debobigegoNoConcatPrev) { + &._debobigegoPanel, ._debobigegoPanel { + border-bottom: solid 0.5px var(--divider); + } + } + + &:not(:first-child):not(._debobigegoNoConcatNext) { + &._debobigegoPanel, ._debobigegoPanel { + border-top: none; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/button.vue b/packages/client/src/components/debobigego/button.vue new file mode 100644 index 0000000000..b883e817a4 --- /dev/null +++ b/packages/client/src/components/debobigego/button.vue @@ -0,0 +1,81 @@ +<template> +<div class="yzpgjkxe _debobigegoItem"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <button class="main _button _debobigegoPanel _debobigegoClickable" :class="{ center, primary, danger }"> + <slot></slot> + <div class="suffix"> + <slot name="suffix"></slot> + <div class="icon"> + <slot name="suffixIcon"></slot> + </div> + </div> + </button> + <div class="_debobigegoCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + primary: { + type: Boolean, + required: false, + default: false, + }, + danger: { + type: Boolean, + required: false, + default: false, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + center: { + type: Boolean, + required: false, + default: true, + } + }, +}); +</script> + +<style lang="scss" scoped> +.yzpgjkxe { + > .main { + display: flex; + width: 100%; + box-sizing: border-box; + padding: 14px 16px; + text-align: left; + align-items: center; + + &.center { + display: block; + text-align: center; + } + + &.primary { + color: var(--accent); + } + + &.danger { + color: #ff2a2a; + } + + > .suffix { + display: inline-flex; + margin-left: auto; + opacity: 0.7; + + > .icon { + margin-left: 1em; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/debobigego.scss b/packages/client/src/components/debobigego/debobigego.scss new file mode 100644 index 0000000000..833b656b66 --- /dev/null +++ b/packages/client/src/components/debobigego/debobigego.scss @@ -0,0 +1,52 @@ +._debobigegoPanel { + background: var(--panel); + border-radius: var(--radius); + transition: background 0.2s ease; + + &._debobigegoClickable { + &:hover { + //background: var(--panelHighlight); + } + + &:active { + background: var(--panelHighlight); + transition: background 0s; + } + } +} + +._debobigegoLabel, +._debobigegoCaption { + font-size: 80%; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } +} + +._debobigegoLabel { + position: sticky; + top: var(--stickyTop, 0px); + z-index: 2; + margin: -8px calc(var(--debobigegoXPadding) * -1) 0 calc(var(--debobigegoXPadding) * -1); + padding: 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)) 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)); + background: var(--X17); + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); +} + +._themeChanging_ ._debobigegoLabel { + transition: none !important; + background: transparent; +} + +._debobigegoCaption { + padding: 8px var(--debobigegoContentHMargin) 0 var(--debobigegoContentHMargin); +} + +._debobigegoItem { + & + ._debobigegoItem { + margin-top: 24px; + } +} diff --git a/packages/client/src/components/debobigego/group.vue b/packages/client/src/components/debobigego/group.vue new file mode 100644 index 0000000000..cba2c6ec94 --- /dev/null +++ b/packages/client/src/components/debobigego/group.vue @@ -0,0 +1,78 @@ +<template> +<div class="vrtktovg _debobigegoItem _debobigegoNoConcat" v-size="{ max: [500] }" v-sticky-container> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="main _debobigego_group" ref="child"> + <slot></slot> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref } from 'vue'; + +export default defineComponent({ + setup(props, context) { + const child = ref<HTMLElement | null>(null); + + const scanChild = () => { + if (child.value == null) return; + const els = Array.from(child.value.children); + for (let i = 0; i < els.length; i++) { + const el = els[i]; + if (el.classList.contains('_debobigegoNoConcat')) { + if (els[i - 1]) els[i - 1].classList.add('_debobigegoNoConcatPrev'); + if (els[i + 1]) els[i + 1].classList.add('_debobigegoNoConcatNext'); + } + } + }; + + onMounted(() => { + scanChild(); + + const observer = new MutationObserver(records => { + scanChild(); + }); + + observer.observe(child.value, { + childList: true, + subtree: false, + attributes: false, + characterData: false, + }); + }); + + return { + child + }; + } +}); +</script> + +<style lang="scss" scoped> +.vrtktovg { + > .main { + > ::v-deep(*):not(._debobigegoNoConcat) { + &:not(._debobigegoNoConcatNext) { + margin: 0; + } + + &:not(:last-child):not(._debobigegoNoConcatPrev) { + &._debobigegoPanel, ._debobigegoPanel { + border-bottom: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + + &:not(:first-child):not(._debobigegoNoConcatNext) { + &._debobigegoPanel, ._debobigegoPanel { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/info.vue b/packages/client/src/components/debobigego/info.vue new file mode 100644 index 0000000000..41afb03304 --- /dev/null +++ b/packages/client/src/components/debobigego/info.vue @@ -0,0 +1,47 @@ +<template> +<div class="fzenkabp _debobigegoItem"> + <div class="_debobigegoPanel" :class="{ warn }"> + <i v-if="warn" class="fas fa-exclamation-triangle"></i> + <i v-else class="fas fa-info-circle"></i> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + warn: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.fzenkabp { + > div { + padding: 14px 16px; + font-size: 90%; + background: var(--infoBg); + color: var(--infoFg); + + &.warn { + background: var(--infoWarnBg); + color: var(--infoWarnFg); + } + + > i { + margin-right: 4px; + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/input.vue b/packages/client/src/components/debobigego/input.vue new file mode 100644 index 0000000000..d113f04d27 --- /dev/null +++ b/packages/client/src/components/debobigego/input.vue @@ -0,0 +1,292 @@ +<template> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="ztzhwixg _debobigegoItem" :class="{ inline, disabled }"> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _debobigegoPanel"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> + </div> + </div> + <template #caption><slot name="desc"></slot></template> + + <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import './debobigego.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + FormButton, + }, + props: { + modelValue: { + required: false + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + step: { + required: false + }, + datalist: { + type: Array, + required: false, + }, + inline: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + setup(props, context) { + const { modelValue, type, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const id = Math.random().toString(); // TODO: uuid? + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + const updated = () => { + changed.value = false; + if (type?.value === 'number') { + context.emit('update:modelValue', parseFloat(v.value)); + } else { + context.emit('update:modelValue', v.value); + } + }; + + watch(modelValue.value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + return { + id, + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + focus, + onInput, + onKeydown, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ztzhwixg { + position: relative; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + $height: 48px; + position: relative; + + > input { + display: block; + height: $height; + width: 100%; + margin: 0; + padding: 0 16px; + font: inherit; + font-weight: normal; + font-size: 1em; + line-height: $height; + color: var(--inputText); + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + box-sizing: border-box; + + &[type='file'] { + display: none; + } + } + + > .prefix, + > .suffix { + display: block; + position: absolute; + z-index: 1; + top: 0; + padding: 0 16px; + font-size: 1em; + line-height: $height; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 8px; + } + + > .suffix { + right: 0; + padding-left: 8px; + } + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/key-value-view.vue b/packages/client/src/components/debobigego/key-value-view.vue new file mode 100644 index 0000000000..0e034a2d54 --- /dev/null +++ b/packages/client/src/components/debobigego/key-value-view.vue @@ -0,0 +1,38 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoPanel anocepby"> + <span class="key"><slot name="key"></slot></span> + <span class="value"><slot name="value"></slot></span> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.anocepby { + display: flex; + align-items: center; + padding: 14px var(--debobigegoContentHMargin); + + > .key { + margin-right: 12px; + white-space: nowrap; + } + + > .value { + margin-left: auto; + opacity: 0.7; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } +} +</style> diff --git a/packages/client/src/components/debobigego/link.vue b/packages/client/src/components/debobigego/link.vue new file mode 100644 index 0000000000..885579eadf --- /dev/null +++ b/packages/client/src/components/debobigego/link.vue @@ -0,0 +1,103 @@ +<template> +<div class="qmfkfnzi _debobigegoItem"> + <a class="main _button _debobigegoPanel _debobigegoClickable" :href="to" target="_blank" v-if="external"> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <span class="right"> + <span class="text"><slot name="suffix"></slot></span> + <i class="fas fa-external-link-alt icon"></i> + </span> + </a> + <MkA class="main _button _debobigegoPanel _debobigegoClickable" :class="{ active }" :to="to" :behavior="behavior" v-else> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <span class="right"> + <span class="text"><slot name="suffix"></slot></span> + <i class="fas fa-chevron-right icon"></i> + </span> + </MkA> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + to: { + type: String, + required: true + }, + active: { + type: Boolean, + required: false + }, + external: { + type: Boolean, + required: false + }, + behavior: { + type: String, + required: false, + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.qmfkfnzi { + > .main { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 14px 16px 14px 14px; + + &:hover { + text-decoration: none; + } + + &.active { + color: var(--accent); + background: var(--panelHighlight); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + + &:empty { + display: none; + + & + .text { + padding-left: 4px; + } + } + } + + > .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 12px; + } + + > .right { + margin-left: auto; + opacity: 0.7; + + > .text:not(:empty) { + margin-right: 0.75em; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/object-view.vue b/packages/client/src/components/debobigego/object-view.vue new file mode 100644 index 0000000000..ea79daa915 --- /dev/null +++ b/packages/client/src/components/debobigego/object-view.vue @@ -0,0 +1,102 @@ +<template> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="drooglns _debobigegoItem" :class="{ tall }"> + <div class="input _debobigegoPanel"> + <textarea class="_monospace" + v-model="v" + readonly + :spellcheck="false" + ></textarea> + </div> + </div> + <template #caption><slot name="desc"></slot></template> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, ref, toRefs, watch } from 'vue'; +import * as JSON5 from 'json5'; +import './debobigego.scss'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + }, + props: { + value: { + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + setup(props, context) { + const { value } = toRefs(props); + const v = ref(''); + + watch(() => value, newValue => { + v.value = JSON5.stringify(newValue.value, null, '\t'); + }, { + immediate: true + }); + + return { + v, + }; + } +}); +</script> + +<style lang="scss" scoped> +.drooglns { + position: relative; + + > .input { + position: relative; + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 16px var(--debobigegoContentHMargin); + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 1em; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + tab-size: 2; + white-space: pre; + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/pagination.vue b/packages/client/src/components/debobigego/pagination.vue new file mode 100644 index 0000000000..07012cb759 --- /dev/null +++ b/packages/client/src/components/debobigego/pagination.vue @@ -0,0 +1,42 @@ +<template> +<FormGroup class="uljviswt _debobigegoItem"> + <template #label><slot name="label"></slot></template> + <slot :items="items"></slot> + <div class="empty" v-if="empty" key="_empty_"> + <slot name="empty"></slot> + </div> + <FormButton v-show="more" class="button" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; +import paging from '@/scripts/paging'; + +export default defineComponent({ + components: { + FormButton, + FormGroup, + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + }, +}); +</script> + +<style lang="scss" scoped> +.uljviswt { +} +</style> diff --git a/packages/client/src/components/debobigego/radios.vue b/packages/client/src/components/debobigego/radios.vue new file mode 100644 index 0000000000..b4c5841337 --- /dev/null +++ b/packages/client/src/components/debobigego/radios.vue @@ -0,0 +1,112 @@ +<script lang="ts"> +import { defineComponent, h } from 'vue'; +import MkRadio from '@/components/form/radio.vue'; +import './debobigego.scss'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + modelValue: { + required: false + }, + }, + data() { + return { + value: this.modelValue, + } + }, + watch: { + modelValue() { + this.value = this.modelValue; + }, + value() { + this.$emit('update:modelValue', this.value); + } + }, + render() { + const label = this.$slots.desc(); + let options = this.$slots.default(); + + // なぜかFragmentになることがあるため + if (options.length === 1 && options[0].props == null) options = options[0].children; + + return h('div', { + class: 'cnklmpwm _debobigegoItem' + }, [ + h('div', { + class: '_debobigegoLabel', + }, label), + ...options.map(option => h('button', { + class: '_button _debobigegoPanel _debobigegoClickable', + key: option.key, + onClick: () => this.value = option.props.value, + }, [h('span', { + class: ['check', { checked: this.value === option.props.value }], + }), option.children])) + ]); + } +}); +</script> + +<style lang="scss"> +.cnklmpwm { + > button { + display: block; + width: 100%; + box-sizing: border-box; + padding: 14px 18px; + text-align: left; + + &:not(:first-of-type) { + border-top: none !important; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &:not(:last-of-type) { + border-bottom: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + > .check { + display: inline-block; + vertical-align: bottom; + position: relative; + width: 16px; + height: 16px; + margin-right: 8px; + background: none; + border: 2px solid var(--inputBorder); + border-radius: 100%; + transition: inherit; + + &:after { + content: ""; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: .4s cubic-bezier(.25,.8,.25,1); + } + + &.checked { + border-color: var(--accent); + + &:after { + background-color: var(--accent); + transform: scale(1); + opacity: 1; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/range.vue b/packages/client/src/components/debobigego/range.vue new file mode 100644 index 0000000000..26fb0f37c6 --- /dev/null +++ b/packages/client/src/components/debobigego/range.vue @@ -0,0 +1,122 @@ +<template> +<div class="ifitouly _debobigegoItem" :class="{ focused, disabled }"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="_debobigegoPanel main"> + <input + type="range" + ref="input" + v-model="v" + :disabled="disabled" + :min="min" + :max="max" + :step="step" + @focus="focused = true" + @blur="focused = false" + @input="$emit('update:value', $event.target.value)" + /> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: false, + default: 0 + }, + disabled: { + type: Boolean, + required: false, + default: false + }, + min: { + type: Number, + required: false, + default: 0 + }, + max: { + type: Number, + required: false, + default: 100 + }, + step: { + type: Number, + required: false, + default: 1 + }, + }, + data() { + return { + v: this.value, + focused: false + }; + }, + watch: { + value(v) { + this.v = parseFloat(v); + } + }, +}); +</script> + +<style lang="scss" scoped> +.ifitouly { + position: relative; + + > .main { + padding: 22px 16px; + + > input { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--X10); + height: 4px; + width: 100%; + box-sizing: border-box; + margin: 0; + outline: 0; + border: 0; + border-radius: 7px; + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + box-sizing: content-box; + } + + &::-moz-range-thumb { + -moz-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/select.vue b/packages/client/src/components/debobigego/select.vue new file mode 100644 index 0000000000..7a31371afc --- /dev/null +++ b/packages/client/src/components/debobigego/select.vue @@ -0,0 +1,145 @@ +<template> +<div class="yrtfrpux _debobigegoItem" :class="{ disabled, inline }"> + <div class="_debobigegoLabel"><slot name="label"></slot></div> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _debobigegoPanel _debobigegoClickable" @click="focus"> + <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> + <select ref="input" + v-model="v" + :required="required" + :disabled="disabled" + @focus="focused = true" + @blur="focused = false" + > + <slot></slot> + </select> + <div class="suffix"> + <i class="fas fa-chevron-down"></i> + </div> + </div> + <div class="_debobigegoCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + modelValue: { + required: false + }, + required: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + }; + }, + computed: { + v: { + get() { + return this.modelValue; + }, + set(v) { + this.$emit('update:modelValue', v); + } + }, + }, + methods: { + focus() { + this.$refs.input.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.yrtfrpux { + position: relative; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + display: flex; + position: relative; + + > select { + display: block; + flex: 1; + width: 100%; + padding: 0 16px; + font: inherit; + font-weight: normal; + font-size: 1em; + height: 48px; + background: none; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + appearance: none; + -webkit-appearance: none; + color: var(--fg); + + option, + optgroup { + color: var(--fg); + background: var(--bg); + } + } + + > .prefix, + > .suffix { + display: block; + align-self: center; + justify-self: center; + font-size: 1em; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: block; + min-width: 16px; + } + } + + > .prefix { + padding-right: 4px; + } + + > .suffix { + padding: 0 16px 0 0; + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/suspense.vue b/packages/client/src/components/debobigego/suspense.vue new file mode 100644 index 0000000000..b5ba63b4b5 --- /dev/null +++ b/packages/client/src/components/debobigego/suspense.vue @@ -0,0 +1,101 @@ +<template> +<transition name="fade" mode="out-in"> + <div class="_debobigegoItem" v-if="pending"> + <div class="_debobigegoPanel"> + <MkLoading/> + </div> + </div> + <div v-else-if="resolved" class="_debobigegoItem"> + <slot :result="result"></slot> + </div> + <div class="_debobigegoItem" v-else> + <div class="_debobigegoPanel eiurkvay"> + <div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div> + <MkButton inline @click="retry" class="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType, ref, watch } from 'vue'; +import './debobigego.scss'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + p: { + type: Function as PropType<() => Promise<any>>, + required: true, + } + }, + + setup(props, context) { + const pending = ref(true); + const resolved = ref(false); + const rejected = ref(false); + const result = ref(null); + + const process = () => { + if (props.p == null) { + return; + } + const promise = props.p(); + pending.value = true; + resolved.value = false; + rejected.value = false; + promise.then((_result) => { + pending.value = false; + resolved.value = true; + result.value = _result; + }); + promise.catch(() => { + pending.value = false; + rejected.value = true; + }); + }; + + watch(() => props.p, () => { + process(); + }, { + immediate: true + }); + + const retry = () => { + process(); + }; + + return { + pending, + resolved, + rejected, + result, + retry, + }; + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.eiurkvay { + padding: 16px; + text-align: center; + + > .retry { + margin-top: 16px; + } +} +</style> diff --git a/packages/client/src/components/debobigego/switch.vue b/packages/client/src/components/debobigego/switch.vue new file mode 100644 index 0000000000..9a69e18302 --- /dev/null +++ b/packages/client/src/components/debobigego/switch.vue @@ -0,0 +1,132 @@ +<template> +<div class="ijnpvmgr _debobigegoItem"> + <div class="main _debobigegoPanel _debobigegoClickable" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click.prevent="toggle" + > + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> + <span class="handle"></span> + </span> + <span class="label"> + <span><slot></slot></span> + </span> + </div> + <div class="_debobigegoCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './debobigego.scss'; + +export default defineComponent({ + props: { + modelValue: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.modelValue; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:modelValue', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ijnpvmgr { + > .main { + position: relative; + display: flex; + padding: 14px 16px; + cursor: pointer; + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 0; + width: 34px; + height: 22px; + background: var(--switchBg); + outline: none; + border-radius: 999px; + transition: all 0.3s; + cursor: pointer; + + > .handle { + position: absolute; + top: 0; + left: 3px; + bottom: 0; + margin: auto 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 16px; + height: 16px; + background-color: #fff; + pointer-events: none; + } + } + + > .label { + margin-left: 12px; + display: block; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent); + + > .handle { + transform: translateX(12px); + } + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/textarea.vue b/packages/client/src/components/debobigego/textarea.vue new file mode 100644 index 0000000000..64e8d47126 --- /dev/null +++ b/packages/client/src/components/debobigego/textarea.vue @@ -0,0 +1,161 @@ +<template> +<FormGroup class="_debobigegoItem"> + <template #label><slot></slot></template> + <div class="rivhosbp _debobigegoItem" :class="{ tall, pre }"> + <div class="input _debobigegoPanel"> + <textarea ref="input" :class="{ code, _monospace: code }" + v-model="v" + :required="required" + :readonly="readonly" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="!code" + @input="onInput" + @focus="focused = true" + @blur="focused = false" + ></textarea> + </div> + </div> + <template #caption><slot name="desc"></slot></template> + + <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent, ref, toRefs, watch } from 'vue'; +import './debobigego.scss'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; + +export default defineComponent({ + components: { + FormGroup, + FormButton, + }, + props: { + modelValue: { + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + autocomplete: { + type: String, + required: false + }, + code: { + type: Boolean, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + setup(props, context) { + const { modelValue } = toRefs(props); + const v = ref(modelValue.value); + const changed = ref(false); + const inputEl = ref(null); + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + watch(modelValue.value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + }); + + return { + v, + updated, + changed, + focus, + onInput, + }; + } +}); +</script> + +<style lang="scss" scoped> +.rivhosbp { + position: relative; + + > .input { + position: relative; + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 16px; + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 1em; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + + &.code { + tab-size: 2; + } + } + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } + + &.pre { + > .input { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/packages/client/src/components/debobigego/tuple.vue b/packages/client/src/components/debobigego/tuple.vue new file mode 100644 index 0000000000..8a4599fd64 --- /dev/null +++ b/packages/client/src/components/debobigego/tuple.vue @@ -0,0 +1,36 @@ +<template> +<div class="wthhikgt _debobigegoItem" v-size="{ max: [500] }"> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ +}); +</script> + +<style lang="scss" scoped> +.wthhikgt { + position: relative; + display: flex; + + > ::v-deep(*) { + flex: 1; + margin: 0; + + &:not(:last-child) { + margin-right: 16px; + } + } + + &.max-width_500px { + display: block; + + > ::v-deep(*) { + margin: inherit; + } + } +} +</style> diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue new file mode 100644 index 0000000000..90086fd430 --- /dev/null +++ b/packages/client/src/components/dialog.vue @@ -0,0 +1,212 @@ +<template> +<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> + <div class="mk-dialog"> + <div class="icon" v-if="icon"> + <i :class="icon"></i> + </div> + <div class="icon" v-else-if="!input && !select" :class="type"> + <i v-if="type === 'success'" class="fas fa-check"></i> + <i v-else-if="type === 'error'" class="fas fa-times-circle"></i> + <i v-else-if="type === 'warning'" class="fas fa-exclamation-triangle"></i> + <i v-else-if="type === 'info'" class="fas fa-info-circle"></i> + <i v-else-if="type === 'question'" class="fas fa-question-circle"></i> + <i v-else-if="type === 'waiting'" class="fas fa-spinner fa-pulse"></i> + </div> + <header v-if="title"><Mfm :text="title"/></header> + <div class="body" v-if="text"><Mfm :text="text"/></div> + <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></MkInput> + <MkSelect v-if="select" v-model="selectedValue" autofocus> + <template v-if="select.items"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </template> + <template v-else> + <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> + <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> + </optgroup> + </template> + </MkSelect> + <div class="buttons" v-if="(showOkButton || showCancelButton) && !actions"> + <MkButton inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select">{{ (showCancelButton || input || select) ? $ts.ok : $ts.gotIt }}</MkButton> + <MkButton inline @click="cancel" v-if="showCancelButton || input || select">{{ $ts.cancel }}</MkButton> + </div> + <div class="buttons" v-if="actions"> + <MkButton v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</MkButton> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; + +export default defineComponent({ + components: { + MkModal, + MkButton, + MkInput, + MkSelect, + }, + + props: { + type: { + type: String, + required: false, + default: 'info' + }, + title: { + type: String, + required: false + }, + text: { + type: String, + required: false + }, + input: { + required: false + }, + select: { + required: false + }, + icon: { + required: false + }, + actions: { + required: false + }, + showOkButton: { + type: Boolean, + default: true + }, + showCancelButton: { + type: Boolean, + default: false + }, + cancelableByBgClick: { + type: Boolean, + default: true + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + inputValue: this.input && this.input.default ? this.input.default : null, + selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null, + }; + }, + + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + + beforeUnmount() { + document.removeEventListener('keydown', this.onKeydown); + }, + + methods: { + done(canceled, result?) { + this.$emit('done', { canceled, result }); + this.$refs.modal.close(); + }, + + async ok() { + if (!this.showOkButton) return; + + const result = + this.input ? this.inputValue : + this.select ? this.selectedValue : + true; + this.done(false, result); + }, + + cancel() { + this.done(true); + }, + + onBgClick() { + if (this.cancelableByBgClick) { + this.cancel(); + } + }, + + onKeydown(e) { + if (e.which === 27) { // ESC + this.cancel(); + } + }, + + onInputKeydown(e) { + if (e.which === 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-dialog { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .icon { + font-size: 32px; + + &.success { + color: var(--success); + } + + &.error { + color: var(--error); + } + + &.warning { + color: var(--warn); + } + + > * { + display: block; + margin: 0 auto; + } + + & + header { + margin-top: 16px; + } + } + + > header { + margin: 0 0 8px 0; + font-weight: bold; + font-size: 20px; + + & + .body { + margin-top: 8px; + } + } + + > .body { + margin: 16px 0 0 0; + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } +} +</style> diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue new file mode 100644 index 0000000000..9b6a0c9a0d --- /dev/null +++ b/packages/client/src/components/drive-file-thumbnail.vue @@ -0,0 +1,108 @@ +<template> +<div class="zdjebgpv" ref="thumbnail"> + <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/> + <i v-else-if="is === 'image'" class="fas fa-file-image icon"></i> + <i v-else-if="is === 'video'" class="fas fa-file-video icon"></i> + <i v-else-if="is === 'audio' || is === 'midi'" class="fas fa-music icon"></i> + <i v-else-if="is === 'csv'" class="fas fa-file-csv icon"></i> + <i v-else-if="is === 'pdf'" class="fas fa-file-pdf icon"></i> + <i v-else-if="is === 'textfile'" class="fas fa-file-alt icon"></i> + <i v-else-if="is === 'archive'" class="fas fa-file-archive icon"></i> + <i v-else class="fas fa-file icon"></i> + + <i v-if="isThumbnailAvailable && is === 'video'" class="fas fa-film icon-sub"></i> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import { ColdDeviceStorage } from '@/store'; + +export default defineComponent({ + components: { + ImgWithBlurhash + }, + props: { + file: { + type: Object, + required: true + }, + fit: { + type: String, + required: false, + default: 'cover' + }, + }, + data() { + return { + isContextmenuShowing: false, + isDragging: false, + + }; + }, + computed: { + is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' { + if (this.file.type.startsWith('image/')) return 'image'; + if (this.file.type.startsWith('video/')) return 'video'; + if (this.file.type === 'audio/midi') return 'midi'; + if (this.file.type.startsWith('audio/')) return 'audio'; + if (this.file.type.endsWith('/csv')) return 'csv'; + if (this.file.type.endsWith('/pdf')) return 'pdf'; + if (this.file.type.startsWith('text/')) return 'textfile'; + if ([ + "application/zip", + "application/x-cpio", + "application/x-bzip", + "application/x-bzip2", + "application/java-archive", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + "application/x-7z-compressed" + ].some(e => e === this.file.type)) return 'archive'; + return 'unknown'; + }, + isThumbnailAvailable(): boolean { + return this.file.thumbnailUrl + ? (this.is === 'image' || this.is === 'video') + : false; + }, + }, + mounted() { + const audioTag = this.$refs.volumectrl as HTMLAudioElement; + if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); + }, + methods: { + volumechange() { + const audioTag = this.$refs.volumectrl as HTMLAudioElement; + ColdDeviceStorage.set('mediaVolume', audioTag.volume); + } + } +}); +</script> + +<style lang="scss" scoped> +.zdjebgpv { + position: relative; + + > .icon-sub { + position: absolute; + width: 30%; + height: auto; + margin: 0; + right: 4%; + bottom: 4%; + } + + > * { + margin: auto; + } + + > .icon { + pointer-events: none; + height: 65%; + width: 65%; + } +} +</style> diff --git a/packages/client/src/components/drive-select-dialog.vue b/packages/client/src/components/drive-select-dialog.vue new file mode 100644 index 0000000000..f9a4025452 --- /dev/null +++ b/packages/client/src/components/drive-select-dialog.vue @@ -0,0 +1,70 @@ +<template> +<XModalWindow ref="dialog" + :width="800" + :height="500" + :with-ok-button="true" + :ok-button-disabled="(type === 'file') && (selected.length === 0)" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <template #header> + {{ multiple ? ((type === 'file') ? $ts.selectFiles : $ts.selectFolders) : ((type === 'file') ? $ts.selectFile : $ts.selectFolder) }} + <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> + </template> + <XDrive :multiple="multiple" @changeSelection="onChangeSelection" @selected="ok()" :select="type"/> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XDrive from './drive.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + XDrive, + XModalWindow, + }, + + props: { + type: { + type: String, + required: false, + default: 'file' + }, + multiple: { + type: Boolean, + default: false + } + }, + + emits: ['done', 'closed'], + + data() { + return { + selected: [] + }; + }, + + methods: { + ok() { + this.$emit('done', this.selected); + this.$refs.dialog.close(); + }, + + cancel() { + this.$emit('done'); + this.$refs.dialog.close(); + }, + + onChangeSelection(xs) { + this.selected = xs; + }, + + number + } +}); +</script> diff --git a/packages/client/src/components/drive-window.vue b/packages/client/src/components/drive-window.vue new file mode 100644 index 0000000000..43f07ebe76 --- /dev/null +++ b/packages/client/src/components/drive-window.vue @@ -0,0 +1,44 @@ +<template> +<XWindow ref="window" + :initial-width="800" + :initial-height="500" + :can-resize="true" + @closed="$emit('closed')" +> + <template #header> + {{ $ts.drive }} + </template> + <XDrive :initial-folder="initialFolder"/> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XDrive from './drive.vue'; +import XWindow from '@/components/ui/window.vue'; + +export default defineComponent({ + components: { + XDrive, + XWindow, + }, + + props: { + initialFolder: { + type: Object, + required: false + }, + }, + + emits: ['closed'], + + data() { + return { + }; + }, + + methods: { + + } +}); +</script> diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/drive.file.vue new file mode 100644 index 0000000000..86f4ee0f8d --- /dev/null +++ b/packages/client/src/components/drive.file.vue @@ -0,0 +1,374 @@ +<template> +<div class="ncvczrfv" + :class="{ isSelected }" + @click="onClick" + @contextmenu.stop="onContextmenu" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + :title="title" +> + <div class="label" v-if="$i.avatarId == file.id"> + <img src="/client-assets/label.svg"/> + <p>{{ $ts.avatar }}</p> + </div> + <div class="label" v-if="$i.bannerId == file.id"> + <img src="/client-assets/label.svg"/> + <p>{{ $ts.banner }}</p> + </div> + <div class="label red" v-if="file.isSensitive"> + <img src="/client-assets/label-red.svg"/> + <p>{{ $ts.nsfw }}</p> + </div> + + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkDriveFileThumbnail + }, + + props: { + file: { + type: Object, + required: true, + }, + isSelected: { + type: Boolean, + required: false, + default: false, + }, + selectMode: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['chosen'], + + data() { + return { + isDragging: false + }; + }, + + computed: { + // TODO: parentへの参照を無くす + browser(): any { + return this.$parent; + }, + title(): string { + return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`; + } + }, + + methods: { + getMenu() { + return [{ + text: this.$ts.rename, + icon: 'fas fa-i-cursor', + action: this.rename + }, { + text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, + icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', + action: this.toggleSensitive + }, { + text: this.$ts.describeFile, + icon: 'fas fa-i-cursor', + action: this.describe + }, null, { + text: this.$ts.copyUrl, + icon: 'fas fa-link', + action: this.copyUrl + }, { + type: 'a', + href: this.file.url, + target: '_blank', + text: this.$ts.download, + icon: 'fas fa-download', + download: this.file.name + }, null, { + text: this.$ts.delete, + icon: 'fas fa-trash-alt', + danger: true, + action: this.deleteFile + }]; + }, + + onClick(ev) { + if (this.selectMode) { + this.$emit('chosen', this.file); + } else { + os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); + } + }, + + onContextmenu(e) { + os.contextMenu(this.getMenu(), e); + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + rename() { + os.dialog({ + title: this.$ts.renameFile, + input: { + placeholder: this.$ts.inputNewFileName, + default: this.file.name, + allowEmpty: false + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/files/update', { + fileId: this.file.id, + name: name + }); + }); + }, + + describe() { + os.popup(import('@/components/media-caption.vue'), { + title: this.$ts.describeFile, + input: { + placeholder: this.$ts.inputNewDescription, + default: this.file.comment !== null ? this.file.comment : '', + }, + image: this.file + }, { + done: result => { + if (!result || result.canceled) return; + let comment = result.result; + os.api('drive/files/update', { + fileId: this.file.id, + comment: comment.length == 0 ? null : comment + }); + } + }, 'closed'); + }, + + toggleSensitive() { + os.api('drive/files/update', { + fileId: this.file.id, + isSensitive: !this.file.isSensitive + }); + }, + + copyUrl() { + copyToClipboard(this.file.url); + os.success(); + }, + + setAsAvatar() { + os.updateAvatar(this.file); + }, + + setAsBanner() { + os.updateBanner(this.file); + }, + + addApp() { + alert('not implemented yet'); + }, + + async deleteFile() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('driveFileDeleteConfirm', { name: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + os.api('drive/files/delete', { + fileId: this.file.id + }); + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> +.ncvczrfv { + position: relative; + padding: 8px 0 0 0; + min-height: 180px; + border-radius: 4px; + + &, * { + cursor: pointer; + } + + > * { + pointer-events: none; + } + + &:hover { + background: rgba(#000, 0.05); + + > .label { + &:before, + &:after { + background: #0b65a5; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + } + } + + &:active { + background: rgba(#000, 0.1); + + > .label { + &:before, + &:after { + background: #0b588c; + } + + &.red { + &:before, + &:after { + background: #ce2212; + } + } + } + } + + &.isSelected { + background: var(--accent); + + &:hover { + background: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + } + + > .label { + &:before, + &:after { + display: none; + } + } + + > .name { + color: #fff; + } + + > .thumbnail { + color: #fff; + } + } + + > .label { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + + &:before, + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1; + background: #0c7ac9; + } + + &:before { + top: 0; + left: 57px; + width: 28px; + height: 8px; + } + + &:after { + top: 57px; + left: 0; + width: 8px; + height: 28px; + } + + &.red { + &:before, + &:after { + background: #c12113; + } + } + + > img { + position: absolute; + z-index: 2; + top: 0; + left: 0; + } + + > p { + position: absolute; + z-index: 3; + top: 19px; + left: -28px; + width: 120px; + margin: 0; + text-align: center; + line-height: 28px; + color: #fff; + transform: rotate(-45deg); + } + } + + > .thumbnail { + width: 110px; + height: 110px; + margin: auto; + } + + > .name { + display: block; + margin: 4px 0 0 0; + font-size: 0.8em; + text-align: center; + word-break: break-all; + color: var(--fg); + overflow: hidden; + + > .ext { + opacity: 0.5; + } + } +} +</style> diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue new file mode 100644 index 0000000000..91e27cc8a1 --- /dev/null +++ b/packages/client/src/components/drive.folder.vue @@ -0,0 +1,326 @@ +<template> +<div class="rghtznwe" + :class="{ draghover }" + @click="onClick" + @contextmenu.stop="onContextmenu" + @mouseover="onMouseover" + @mouseout="onMouseout" + @dragover.prevent.stop="onDragover" + @dragenter.prevent="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + :title="title" +> + <p class="name"> + <template v-if="hover"><i class="fas fa-folder-open fa-fw"></i></template> + <template v-if="!hover"><i class="fas fa-folder fa-fw"></i></template> + {{ folder.name }} + </p> + <p class="upload" v-if="$store.state.uploadFolder == folder.id"> + {{ $ts.uploadFolder }} + </p> + <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + folder: { + type: Object, + required: true, + }, + isSelected: { + type: Boolean, + required: false, + default: false, + }, + selectMode: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['chosen'], + + data() { + return { + hover: false, + draghover: false, + isDragging: false, + }; + }, + + computed: { + browser(): any { + return this.$parent; + }, + title(): string { + return this.folder.name; + } + }, + + methods: { + checkboxClicked(e) { + this.$emit('chosen', this.folder); + }, + + onClick() { + this.browser.move(this.folder); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false + }, + + onDragover(e) { + // 自分自身がドラッグされている場合 + if (this.isDragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDragenter() { + if (!this.isDragging) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.browser.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: this.folder.id + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.id == this.folder.id) return; + + this.browser.removeFolder(folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder.id + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.dialog({ + title: this.$ts.unableToProcess, + text: this.$ts.circularReferenceFolder + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + } + }); + } + //#endregion + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(this.folder)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend() { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + go() { + this.browser.move(this.folder.id); + }, + + newWindow() { + this.browser.newWindow(this.folder); + }, + + rename() { + os.dialog({ + title: this.$ts.renameFolder, + input: { + placeholder: this.$ts.inputNewFolderName, + default: this.folder.name + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: this.folder.id, + name: name + }); + }); + }, + + deleteFolder() { + os.api('drive/folders/delete', { + folderId: this.folder.id + }).then(() => { + if (this.$store.state.uploadFolder === this.folder.id) { + this.$store.set('uploadFolder', null); + } + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + os.dialog({ + type: 'error', + title: this.$ts.unableToDelete, + text: this.$ts.hasChildFilesOrFolders + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.unableToDelete + }); + } + }); + }, + + setAsUploadFolder() { + this.$store.set('uploadFolder', this.folder.id); + }, + + onContextmenu(e) { + os.contextMenu([{ + text: this.$ts.openInWindow, + icon: 'fas fa-window-restore', + action: () => { + os.popup(import('./drive-window.vue'), { + initialFolder: this.folder + }, { + }, 'closed'); + } + }, null, { + text: this.$ts.rename, + icon: 'fas fa-i-cursor', + action: this.rename + }, null, { + text: this.$ts.delete, + icon: 'fas fa-trash-alt', + danger: true, + action: this.deleteFolder + }], e); + }, + } +}); +</script> + +<style lang="scss" scoped> +.rghtznwe { + position: relative; + padding: 8px; + height: 64px; + background: var(--driveFolderBg); + border-radius: 4px; + + &, * { + cursor: pointer; + } + + *:not(.checkbox) { + pointer-events: none; + } + + > .checkbox { + position: absolute; + bottom: 8px; + right: 8px; + width: 16px; + height: 16px; + background: #fff; + border: solid 1px #000; + + &.checked { + background: var(--accent); + } + } + + &.draghover { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -4px; + right: -4px; + bottom: -4px; + left: -4px; + border: 2px dashed var(--focus); + border-radius: 4px; + } + } + + > .name { + margin: 0; + font-size: 0.9em; + color: var(--desktopDriveFolderFg); + + > i { + margin-right: 4px; + margin-left: 2px; + text-align: left; + } + } + + > .upload { + margin: 4px 4px; + font-size: 0.8em; + text-align: right; + color: var(--desktopDriveFolderFg); + } +} +</style> diff --git a/packages/client/src/components/drive.nav-folder.vue b/packages/client/src/components/drive.nav-folder.vue new file mode 100644 index 0000000000..4f0e6ce0e9 --- /dev/null +++ b/packages/client/src/components/drive.nav-folder.vue @@ -0,0 +1,135 @@ +<template> +<div class="drylbebk" + :class="{ draghover }" + @click="onClick" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <i v-if="folder == null" class="fas fa-cloud"></i> + <span>{{ folder == null ? $ts.drive : folder.name }}</span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + folder: { + type: Object, + required: false, + } + }, + + data() { + return { + hover: false, + draghover: false, + }; + }, + + computed: { + browser(): any { + return this.$parent; + } + }, + + methods: { + onClick() { + this.browser.move(this.folder); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false; + }, + + onDragover(e) { + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (this.folder == null && this.browser.folder == null) { + e.dataTransfer.dropEffect = 'none'; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter() { + if (this.folder || this.browser.folder) this.draghover = true; + }, + + onDragleave() { + if (this.folder || this.browser.folder) this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.browser.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return; + this.browser.removeFolder(folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }); + } + //#endregion + } + } +}); +</script> + +<style lang="scss" scoped> +.drylbebk { + > * { + pointer-events: none; + } + + &.draghover { + background: #eee; + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue new file mode 100644 index 0000000000..2b72a0a1c6 --- /dev/null +++ b/packages/client/src/components/drive.vue @@ -0,0 +1,784 @@ +<template> +<div class="yfudmmck"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <XNavFolder :class="{ current: folder == null }"/> + <template v-for="f in hierarchyFolders"> + <span class="separator"><i class="fas fa-angle-right"></i></span> + <XNavFolder :folder="f"/> + </template> + <span class="separator" v-if="folder != null"><i class="fas fa-angle-right"></i></span> + <span class="folder current" v-if="folder != null">{{ folder.name }}</span> + </div> + <button @click="showMenu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button> + </nav> + <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" + ref="main" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.stop="onContextmenu" + > + <div class="contents" ref="contents"> + <div class="folders" ref="foldersContainer" v-show="folders.length > 0"> + <XFolder v-for="(f, i) in folders" :key="f.id" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" v-anim="i"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="(n, i) in 16" :key="i"></div> + <MkButton ref="moreFolders" v-if="moreFolders">{{ $ts.loadMore }}</MkButton> + </div> + <div class="files" ref="filesContainer" v-show="files.length > 0"> + <XFile v-for="(file, i) in files" :key="file.id" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" v-anim="i"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="(n, i) in 16" :key="i"></div> + <MkButton ref="loadMoreFiles" @click="fetchMoreFiles" v-show="moreFiles">{{ $ts.loadMore }}</MkButton> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="draghover">{{ $t('empty-draghover') }}</p> + <p v-if="!draghover && folder == null"><strong>{{ $ts.emptyDrive }}</strong><br/>{{ $t('empty-drive-description') }}</p> + <p v-if="!draghover && folder != null">{{ $ts.emptyFolder }}</p> + </div> + </div> + <MkLoading v-if="fetching"/> + </div> + <div class="dropzone" v-if="draghover"></div> + <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import XNavFolder from './drive.nav-folder.vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import MkButton from './ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNavFolder, + XFolder, + XFile, + MkButton, + }, + + props: { + initialFolder: { + type: Object, + required: false + }, + type: { + type: String, + required: false, + default: undefined + }, + multiple: { + type: Boolean, + required: false, + default: false + }, + select: { + type: String, + required: false, + default: null + } + }, + + emits: ['selected', 'change-selection', 'move-root', 'cd', 'open-folder'], + + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + selectedFolders: [], + uploadings: os.uploads, + connection: null, + + /** + * ドロップされようとしているか + */ + draghover: false, + + /** + * 自信の所有するアイテムがドラッグをスタートさせたか + * (自分自身の階層にドロップできないようにするためのフラグ) + */ + isDragSource: false, + + fetching: true, + + ilFilesObserver: new IntersectionObserver( + (entries) => entries.some((entry) => entry.isIntersecting) + && !this.fetching && this.moreFiles && + this.fetchMoreFiles() + ), + moreFilesElement: null as Element, + + }; + }, + + watch: { + folder() { + this.$emit('cd', this.folder); + } + }, + + mounted() { + if (this.$store.state.enableInfiniteScroll && this.$refs.loadMoreFiles) { + this.$nextTick(() => { + this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) + }); + } + + this.connection = markRaw(os.stream.useChannel('drive')); + + this.connection.on('fileCreated', this.onStreamDriveFileCreated); + this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); + this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); + this.connection.on('folderCreated', this.onStreamDriveFolderCreated); + this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); + this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted); + + if (this.initialFolder) { + this.move(this.initialFolder); + } else { + this.fetch(); + } + }, + + activated() { + if (this.$store.state.enableInfiniteScroll) { + this.$nextTick(() => { + this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) + }); + } + }, + + beforeUnmount() { + this.connection.dispose(); + this.ilFilesObserver.disconnect(); + }, + + methods: { + onStreamDriveFileCreated(file) { + this.addFile(file, true); + }, + + onStreamDriveFileUpdated(file) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFileDeleted(fileId) { + this.removeFile(fileId); + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + onStreamDriveFolderDeleted(folderId) { + this.removeFolder(folderId); + }, + + onDragover(e): any { + // ドラッグ元が自分自身の所有するアイテムだったら + if (this.isDragSource) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter(e) { + if (!this.isDragSource) this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): any { + this.draghover = false; + + // ドロップされてきたものがファイルだったら + if (e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + this.upload(file, this.folder); + } + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + if (this.files.some(f => f.id == file.id)) return; + this.removeFile(file.id); + os.api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return false; + if (this.folders.some(f => f.id == folder.id)) return false; + this.removeFolder(folder.id); + os.api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + os.dialog({ + title: this.$ts.unableToProcess, + text: this.$ts.circularReferenceFolder + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + } + }); + } + //#endregion + }, + + selectLocalFile() { + (this.$refs.fileInput as any).click(); + }, + + urlUpload() { + os.dialog({ + title: this.$ts.uploadFromUrl, + input: { + placeholder: this.$ts.uploadFromUrlDescription + } + }).then(({ canceled, result: url }) => { + if (canceled) return; + os.api('drive/files/upload-from-url', { + url: url, + folderId: this.folder ? this.folder.id : undefined + }); + + os.dialog({ + title: this.$ts.uploadFromUrlRequested, + text: this.$ts.uploadFromUrlMayTakeTime + }); + }); + }, + + createFolder() { + os.dialog({ + title: this.$ts.createFolder, + input: { + placeholder: this.$ts.folderName + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/create', { + name: name, + parentId: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }); + }, + + renameFolder(folder) { + os.dialog({ + title: this.$ts.renameFolder, + input: { + placeholder: this.$ts.inputNewFolderName, + default: folder.name + } + }).then(({ canceled, result: name }) => { + if (canceled) return; + os.api('drive/folders/update', { + folderId: folder.id, + name: name + }).then(folder => { + // FIXME: 画面を更新するために自分自身に移動 + this.move(folder); + }); + }); + }, + + deleteFolder(folder) { + os.api('drive/folders/delete', { + folderId: folder.id + }).then(() => { + // 削除時に親フォルダに移動 + this.move(folder.parentId); + }).catch(err => { + switch(err.id) { + case 'b0fc8a17-963c-405d-bfbc-859a487295e1': + os.dialog({ + type: 'error', + title: this.$ts.unableToDelete, + text: this.$ts.hasChildFilesOrFolders + }); + break; + default: + os.dialog({ + type: 'error', + text: this.$ts.unableToDelete + }); + } + }); + }, + + onChangeFileInput() { + for (const file of Array.from((this.$refs.fileInput as any).files)) { + this.upload(file, this.folder); + } + }, + + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + os.upload(file, folder).then(res => { + this.addFile(res, true); + }); + }, + + chooseFile(file) { + const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + if (isAlreadySelected) { + this.$emit('selected', file); + } else { + this.selectedFiles = [file]; + this.$emit('change-selection', [file]); + } + } + }, + + chooseFolder(folder) { + const isAlreadySelected = this.selectedFolders.some(f => f.id == folder.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFolders = this.selectedFolders.filter(f => f.id != folder.id); + } else { + this.selectedFolders.push(folder); + } + this.$emit('change-selection', this.selectedFolders); + } else { + if (isAlreadySelected) { + this.$emit('selected', folder); + } else { + this.selectedFolders = [folder]; + this.$emit('change-selection', [folder]); + } + } + }, + + move(target) { + if (target == null) { + this.goRoot(); + return; + } else if (typeof target == 'object') { + target = target.id; + } + + this.fetching = true; + + os.api('drive/folders/show', { + folderId: target + }).then(folder => { + this.folder = folder; + this.hierarchyFolders = []; + + const dive = folder => { + this.hierarchyFolders.unshift(folder); + if (folder.parent) dive(folder.parent); + }; + + if (folder.parent) dive(folder.parent); + + this.$emit('open-folder', folder); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) return; + + if (this.folders.some(f => f.id == folder.id)) { + const exist = this.folders.map(f => f.id).indexOf(folder.id); + this.folders[exist] = folder; + return; + } + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + this.files[exist] = file; + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + + appendFolder(folder) { + this.addFolder(folder); + }, + + prependFile(file) { + this.addFile(file, true); + }, + + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot() { + // 既にrootにいるなら何もしない + if (this.folder == null) return; + + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root'); + this.fetch(); + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 30; + const filesMax = 30; + + // フォルダ一覧取得 + os.api('drive/folders', { + folderId: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + os.api('drive/files', { + folderId: this.folder ? this.folder.id : null, + type: this.type, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + for (const x of fetchedFolders) this.appendFolder(x); + for (const x of fetchedFiles) this.appendFile(x); + this.fetching = false; + } else { + flag = true; + } + }; + }, + + fetchMoreFiles() { + this.fetching = true; + + const max = 30; + + // ファイル一覧取得 + os.api('drive/files', { + folderId: this.folder ? this.folder.id : null, + type: this.type, + untilId: this.files[this.files.length - 1].id, + limit: max + 1 + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + for (const x of files) this.appendFile(x); + this.fetching = false; + }); + }, + + getMenu() { + return [{ + text: this.$ts.addFile, + type: 'label' + }, { + text: this.$ts.upload, + icon: 'fas fa-upload', + action: () => { this.selectLocalFile(); } + }, { + text: this.$ts.fromUrl, + icon: 'fas fa-link', + action: () => { this.urlUpload(); } + }, null, { + text: this.folder ? this.folder.name : this.$ts.drive, + type: 'label' + }, this.folder ? { + text: this.$ts.renameFolder, + icon: 'fas fa-i-cursor', + action: () => { this.renameFolder(this.folder); } + } : undefined, this.folder ? { + text: this.$ts.deleteFolder, + icon: 'fas fa-trash-alt', + action: () => { this.deleteFolder(this.folder); } + } : undefined, { + text: this.$ts.createFolder, + icon: 'fas fa-folder-plus', + action: () => { this.createFolder(); } + }]; + }, + + showMenu(ev) { + os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); + }, + + onContextmenu(ev) { + os.contextMenu(this.getMenu(), ev); + }, + } +}); +</script> + +<style lang="scss" scoped> +.yfudmmck { + display: flex; + flex-direction: column; + height: 100%; + + > nav { + display: flex; + z-index: 2; + width: 100%; + padding: 0 8px; + box-sizing: border-box; + overflow: auto; + font-size: 0.9em; + box-shadow: 0 1px 0 var(--divider); + + &, * { + user-select: none; + } + + > .path { + display: inline-block; + vertical-align: bottom; + line-height: 38px; + white-space: nowrap; + + > * { + display: inline-block; + margin: 0; + padding: 0 8px; + line-height: 38px; + cursor: pointer; + + * { + pointer-events: none; + } + + &:hover { + text-decoration: underline; + } + + &.current { + font-weight: bold; + cursor: default; + + &:hover { + text-decoration: none; + } + } + + &.separator { + margin: 0; + padding: 0; + opacity: 0.5; + cursor: default; + + > i { + margin: 0; + } + } + } + } + + > .menu { + margin-left: auto; + } + } + + > .main { + flex: 1; + overflow: auto; + padding: var(--margin); + + &, * { + user-select: none; + } + + &.fetching { + cursor: wait !important; + + * { + pointer-events: none; + } + + > .contents { + opacity: 0.5; + } + } + + &.uploading { + height: calc(100% - 38px - 100px); + } + + > .contents { + + > .folders, + > .files { + display: flex; + flex-wrap: wrap; + + > .folder, + > .file { + flex-grow: 1; + width: 128px; + margin: 4px; + box-sizing: border-box; + } + + > .padding { + flex-grow: 1; + pointer-events: none; + width: 128px + 8px; + } + } + + > .empty { + padding: 16px; + text-align: center; + pointer-events: none; + opacity: 0.5; + + > p { + margin: 0; + } + } + } + } + + > .dropzone { + position: absolute; + left: 0; + top: 38px; + width: 100%; + height: calc(100% - 38px); + border: dashed 2px var(--focus); + pointer-events: none; + } + + > input { + display: none; + } +} +</style> diff --git a/packages/client/src/components/emoji-picker-dialog.vue b/packages/client/src/components/emoji-picker-dialog.vue new file mode 100644 index 0000000000..1d48bbb8a3 --- /dev/null +++ b/packages/client/src/components/emoji-picker-dialog.vue @@ -0,0 +1,76 @@ +<template> +<MkPopup ref="popup" :manual-showing="manualShowing" :src="src" :front="true" @click="$refs.popup.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')" #default="{point}"> + <MkEmojiPicker class="ryghynhb _popup _shadow" :class="{ pointer: point === 'top' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen" ref="picker"/> +</MkPopup> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkPopup from '@/components/ui/popup.vue'; +import MkEmojiPicker from '@/components/emoji-picker.vue'; + +export default defineComponent({ + components: { + MkPopup, + MkEmojiPicker, + }, + + props: { + manualShowing: { + type: Boolean, + required: false, + default: null, + }, + src: { + required: false + }, + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['done', 'close', 'closed'], + + data() { + return { + + }; + }, + + methods: { + chosen(emoji: any) { + this.$emit('done', emoji); + this.$refs.popup.close(); + }, + + opening() { + this.$refs.picker.reset(); + this.$refs.picker.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.ryghynhb { + &.pointer { + &:before { + --size: 8px; + content: ''; + display: block; + position: absolute; + top: calc(0px - (var(--size) * 2)); + left: 0; + right: 0; + width: 0; + margin: auto; + border: solid var(--size) transparent; + border-bottom-color: var(--popup); + } + } +} +</style> diff --git a/packages/client/src/components/emoji-picker-window.vue b/packages/client/src/components/emoji-picker-window.vue new file mode 100644 index 0000000000..0ffa0c1187 --- /dev/null +++ b/packages/client/src/components/emoji-picker-window.vue @@ -0,0 +1,197 @@ +<template> +<MkWindow ref="window" + :initial-width="null" + :initial-height="null" + :can-resize="false" + :mini="true" + :front="true" + @closed="$emit('closed')" +> + <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> +</MkWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkWindow from '@/components/ui/window.vue'; +import MkEmojiPicker from '@/components/emoji-picker.vue'; + +export default defineComponent({ + components: { + MkWindow, + MkEmojiPicker, + }, + + props: { + src: { + required: false + }, + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['chosen', 'closed'], + + data() { + return { + + }; + }, + + methods: { + chosen(emoji: any) { + this.$emit('chosen', emoji); + }, + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + --eachSize: 40px; + + display: flex; + flex-direction: column; + contain: content; + + &.big { + --eachSize: 44px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + } + + &.h1 { + --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .emojis { + height: var(--height); + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > .index { + min-height: var(--height); + position: relative; + border-bottom: solid 0.5px var(--divider); + + > .arrow { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 16px 0; + text-align: center; + opacity: 0.5; + pointer-events: none; + } + } + + section { + > header { + position: sticky; + top: 0; + left: 0; + z-index: 1; + padding: 8px; + font-size: 12px; + } + + > div { + padding: $pad; + + > button { + position: relative; + padding: 0; + width: var(--eachSize); + height: var(--eachSize); + border-radius: 4px; + + &:focus-visible { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > * { + font-size: 24px; + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 0.5px var(--divider); + + &:empty { + display: none; + } + } + + &.unicode { + min-height: 384px; + } + + &.custom { + min-height: 64px; + } + } + } +} +</style> diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/emoji-picker.section.vue new file mode 100644 index 0000000000..2401eca2a5 --- /dev/null +++ b/packages/client/src/components/emoji-picker.section.vue @@ -0,0 +1,50 @@ +<template> +<section> + <header class="_acrylic" @click="shown = !shown"> + <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }}) + </header> + <div v-if="shown"> + <button v-for="emoji in emojis" + class="_button" + @click="chosen(emoji, $event)" + :key="emoji" + > + <MkEmoji :emoji="emoji" :normal="true"/> + </button> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; + +export default defineComponent({ + props: { + emojis: { + required: true, + }, + initialShown: { + required: false + } + }, + + emits: ['chosen'], + + data() { + return { + getStaticImageUrl, + shown: this.initialShown, + }; + }, + + methods: { + chosen(emoji: any, ev) { + this.$parent.chosen(emoji, ev); + }, + } +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue new file mode 100644 index 0000000000..015e201269 --- /dev/null +++ b/packages/client/src/components/emoji-picker.vue @@ -0,0 +1,501 @@ +<template> +<div class="omfetrab" :class="['w' + width, 'h' + height, { big }]"> + <input ref="search" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" v-model.trim="q" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()"> + <div class="emojis" ref="emojis"> + <section class="result"> + <div v-if="searchResultCustom.length > 0"> + <button v-for="emoji in searchResultCustom" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji" + tabindex="0" + > + <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> + <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + </button> + </div> + <div v-if="searchResultUnicode.length > 0"> + <button v-for="emoji in searchResultUnicode" + class="_button" + :title="emoji.name" + @click="chosen(emoji, $event)" + :key="emoji.name" + tabindex="0" + > + <MkEmoji :emoji="emoji.char"/> + </button> + </div> + </section> + + <div class="index" v-if="tab === 'index'"> + <section v-if="showPinned"> + <div> + <button v-for="emoji in pinned" + class="_button" + @click="chosen(emoji, $event)" + tabindex="0" + :key="emoji" + > + <MkEmoji :emoji="emoji" :normal="true"/> + </button> + </div> + </section> + + <section> + <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ $ts.recentUsed }}</header> + <div> + <button v-for="emoji in $store.state.recentlyUsedEmojis" + class="_button" + @click="chosen(emoji, $event)" + :key="emoji" + > + <MkEmoji :emoji="emoji" :normal="true"/> + </button> + </div> + </section> + </div> + <div> + <header class="_acrylic">{{ $ts.customEmojis }}</header> + <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')">{{ category || $ts.other }}</XSection> + </div> + <div> + <header class="_acrylic">{{ $ts.emoji }}</header> + <XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)">{{ category }}</XSection> + </div> + </div> + <div class="tabs"> + <button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="fas fa-asterisk fa-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="fas fa-laugh fa-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="fas fa-leaf fa-fw"></i></button> + <button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="fas fa-hashtag fa-fw"></i></button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { emojilist } from '@/scripts/emojilist'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import Particle from '@/components/particle.vue'; +import * as os from '@/os'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import { isMobile } from '@/scripts/is-mobile'; +import { emojiCategories } from '@/instance'; +import XSection from './emoji-picker.section.vue'; + +export default defineComponent({ + components: { + XSection + }, + + props: { + showPinned: { + required: false, + default: true + }, + asReactionPicker: { + required: false + }, + }, + + emits: ['chosen'], + + data() { + return { + emojilist: markRaw(emojilist), + getStaticImageUrl, + pinned: this.$store.reactiveState.reactions, + width: this.asReactionPicker ? this.$store.state.reactionPickerWidth : 3, + height: this.asReactionPicker ? this.$store.state.reactionPickerHeight : 2, + big: this.asReactionPicker ? isDeviceTouch : false, + customEmojiCategories: emojiCategories, + customEmojis: this.$instance.emojis, + q: null, + searchResultCustom: [], + searchResultUnicode: [], + tab: 'index', + categories: ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'], + }; + }, + + watch: { + q() { + this.$refs.emojis.scrollTop = 0; + + if (this.q == null || this.q === '') { + this.searchResultCustom = []; + this.searchResultUnicode = []; + return; + } + + const q = this.q.replace(/:/g, ''); + + const searchCustom = () => { + const max = 8; + const emojis = this.customEmojis; + const matches = new Set(); + + const exactMatch = emojis.find(e => e.name === q); + if (exactMatch) matches.add(exactMatch); + + if (q.includes(' ')) { // AND検索 + const keywords = q.split(' '); + + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.startsWith(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.aliases.some(alias => alias.includes(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } + + return matches; + }; + + const searchUnicode = () => { + const max = 8; + const emojis = this.emojilist; + const matches = new Set(); + + const exactMatch = emojis.find(e => e.name === q); + if (exactMatch) matches.add(exactMatch); + + if (q.includes(' ')) { // AND検索 + const keywords = q.split(' '); + + // 名前にキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + // 名前またはエイリアスにキーワードが含まれている + for (const emoji of emojis) { + if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } else { + for (const emoji of emojis) { + if (emoji.name.startsWith(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.startsWith(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.name.includes(q)) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + if (matches.size >= max) return matches; + + for (const emoji of emojis) { + if (emoji.keywords.some(keyword => keyword.includes(q))) { + matches.add(emoji); + if (matches.size >= max) break; + } + } + } + + return matches; + }; + + this.searchResultCustom = Array.from(searchCustom()); + this.searchResultUnicode = Array.from(searchUnicode()); + } + }, + + mounted() { + this.focus(); + }, + + methods: { + focus() { + if (!isMobile && !isDeviceTouch) { + this.$refs.search.focus({ + preventScroll: true + }); + } + }, + + reset() { + this.$refs.emojis.scrollTop = 0; + this.q = ''; + }, + + getKey(emoji: any) { + return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`); + }, + + chosen(emoji: any, ev) { + if (ev) { + const el = ev.currentTarget || ev.target; + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.clientWidth / 2); + const y = rect.top + (el.clientHeight / 2); + os.popup(Particle, { x, y }, {}, 'end'); + } + + const key = this.getKey(emoji); + this.$emit('chosen', key); + + // 最近使った絵文字更新 + if (!this.pinned.includes(key)) { + let recents = this.$store.state.recentlyUsedEmojis; + recents = recents.filter((e: any) => e !== key); + recents.unshift(key); + this.$store.set('recentlyUsedEmojis', recents.splice(0, 32)); + } + }, + + paste(event) { + const paste = (event.clipboardData || window.clipboardData).getData('text'); + if (this.done(paste)) { + event.preventDefault(); + } + }, + + done(query) { + if (query == null) query = this.q; + if (query == null) return; + const q = query.replace(/:/g, ''); + const exactMatchCustom = this.customEmojis.find(e => e.name === q); + if (exactMatchCustom) { + this.chosen(exactMatchCustom); + return true; + } + const exactMatchUnicode = this.emojilist.find(e => e.char === q || e.name === q); + if (exactMatchUnicode) { + this.chosen(exactMatchUnicode); + return true; + } + if (this.searchResultCustom.length > 0) { + this.chosen(this.searchResultCustom[0]); + return true; + } + if (this.searchResultUnicode.length > 0) { + this.chosen(this.searchResultUnicode[0]); + return true; + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.omfetrab { + $pad: 8px; + --eachSize: 40px; + + display: flex; + flex-direction: column; + + &.big { + --eachSize: 44px; + } + + &.w1 { + width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); + } + + &.w2 { + width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.w3 { + width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); + } + + &.h1 { + --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + } + + &.h2 { + --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + } + + &.h3 { + --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + } + + > .search { + width: 100%; + padding: 12px; + box-sizing: border-box; + font-size: 1em; + outline: none; + border: none; + background: transparent; + color: var(--fg); + + &:not(.filled) { + order: 1; + z-index: 2; + box-shadow: 0px -1px 0 0px var(--divider); + } + } + + > .tabs { + display: flex; + display: none; + + > .tab { + flex: 1; + height: 38px; + border-top: solid 0.5px var(--divider); + + &.active { + border-top: solid 1px var(--accent); + color: var(--accent); + } + } + } + + > .emojis { + height: var(--height); + overflow-y: auto; + overflow-x: hidden; + + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + > div { + &:not(.index) { + padding: 4px 0 8px 0; + border-top: solid 0.5px var(--divider); + } + + > header { + /*position: sticky; + top: 0; + left: 0;*/ + height: 32px; + line-height: 32px; + z-index: 2; + padding: 0 8px; + font-size: 12px; + } + } + + ::v-deep(section) { + > header { + position: sticky; + top: 0; + left: 0; + height: 32px; + line-height: 32px; + z-index: 1; + padding: 0 8px; + font-size: 12px; + cursor: pointer; + + &:hover { + color: var(--accent); + } + } + + > div { + position: relative; + padding: $pad; + + > button { + position: relative; + padding: 0; + width: var(--eachSize); + height: var(--eachSize); + border-radius: 4px; + + &:focus-visible { + outline: solid 2px var(--focus); + z-index: 1; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: var(--accent); + box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); + } + + > * { + font-size: 24px; + height: 1.25em; + vertical-align: -.25em; + pointer-events: none; + } + } + } + + &.result { + border-bottom: solid 0.5px var(--divider); + + &:empty { + display: none; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/featured-photos.vue b/packages/client/src/components/featured-photos.vue new file mode 100644 index 0000000000..276344dfb4 --- /dev/null +++ b/packages/client/src/components/featured-photos.vue @@ -0,0 +1,32 @@ +<template> +<div class="xfbouadm" v-if="meta" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + }, + + data() { + return { + meta: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, +}); +</script> + +<style lang="scss" scoped> +.xfbouadm { + background-position: center; + background-size: cover; +} +</style> diff --git a/packages/client/src/components/file-type-icon.vue b/packages/client/src/components/file-type-icon.vue new file mode 100644 index 0000000000..be1af5e501 --- /dev/null +++ b/packages/client/src/components/file-type-icon.vue @@ -0,0 +1,28 @@ +<template> +<span class="mk-file-type-icon"> + <template v-if="kind == 'image'"><i class="fas fa-file-image"></i></template> +</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + type: { + type: String, + required: true, + } + }, + data() { + return { + }; + }, + computed: { + kind(): string { + return this.type.split('/')[0]; + } + } +}); +</script> diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/follow-button.vue new file mode 100644 index 0000000000..a96899027f --- /dev/null +++ b/packages/client/src/components/follow-button.vue @@ -0,0 +1,210 @@ +<template> +<button class="kpoogebi _button" + :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }" + @click="onClick" + :disabled="wait" +> + <template v-if="!wait"> + <template v-if="hasPendingFollowRequestFromYou && user.isLocked"> + <span v-if="full">{{ $ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i> + </template> + <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 --> + <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i> + </template> + <template v-else-if="isFollowing"> + <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i> + </template> + <template v-else-if="!isFollowing && user.isLocked"> + <span v-if="full">{{ $ts.followRequest }}</span><i class="fas fa-plus"></i> + </template> + <template v-else-if="!isFollowing && !user.isLocked"> + <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i> + </template> + </template> + <template v-else> + <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> + </template> +</button> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + full: { + type: Boolean, + required: false, + default: false, + }, + large: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isFollowing: this.user.isFollowing, + hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou, + wait: false, + connection: null, + }; + }, + + created() { + // 渡されたユーザー情報が不完全な場合 + if (this.user.isFollowing == null) { + os.api('users/show', { + userId: this.user.id + }).then(u => { + this.isFollowing = u.isFollowing; + this.hasPendingFollowRequestFromYou = u.hasPendingFollowRequestFromYou; + }); + } + }, + + mounted() { + this.connection = markRaw(os.stream.useChannel('main')); + + this.connection.on('follow', this.onFollowChange); + this.connection.on('unfollow', this.onFollowChange); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + onFollowChange(user) { + if (user.id == this.user.id) { + this.isFollowing = user.isFollowing; + this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; + } + }, + + async onClick() { + this.wait = true; + + try { + if (this.isFollowing) { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }), + showCancelButton: true + }); + + if (canceled) return; + + await os.api('following/delete', { + userId: this.user.id + }); + } else { + if (this.hasPendingFollowRequestFromYou) { + await os.api('following/requests/cancel', { + userId: this.user.id + }); + } else if (this.user.isLocked) { + await os.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } else { + await os.api('following/create', { + userId: this.user.id + }); + this.hasPendingFollowRequestFromYou = true; + } + } + } catch (e) { + console.error(e); + } finally { + this.wait = false; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.kpoogebi { + position: relative; + display: inline-block; + font-weight: bold; + color: var(--accent); + background: transparent; + border: solid 1px var(--accent); + padding: 0; + height: 31px; + font-size: 16px; + border-radius: 32px; + background: #fff; + + &.full { + padding: 0 8px 0 12px; + font-size: 14px; + } + + &.large { + font-size: 16px; + height: 38px; + padding: 0 12px 0 16px; + } + + &:not(.full) { + width: 31px; + } + + &:focus-visible { + &:after { + content: ""; + pointer-events: none; + position: absolute; + top: -5px; + right: -5px; + bottom: -5px; + left: -5px; + border: 2px solid var(--focus); + border-radius: 32px; + } + } + + &:hover { + //background: mix($primary, #fff, 20); + } + + &:active { + //background: mix($primary, #fff, 40); + } + + &.active { + color: #fff; + background: var(--accent); + + &:hover { + background: var(--accentLighten); + border-color: var(--accentLighten); + } + + &:active { + background: var(--accentDarken); + border-color: var(--accentDarken); + } + } + + &.wait { + cursor: wait !important; + opacity: 0.7; + } + + > span { + margin-right: 6px; + } +} +</style> diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/forgot-password.vue new file mode 100644 index 0000000000..a42ea5864a --- /dev/null +++ b/packages/client/src/components/forgot-password.vue @@ -0,0 +1,84 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :height="400" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.forgotPassword }}</template> + + <form class="bafeceda" @submit.prevent="onSubmit" v-if="$instance.enableEmail"> + <div class="main _formRoot"> + <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + </MkInput> + + <MkInput class="_formBlock" v-model="email" type="email" spellcheck="false" required> + <template #label>{{ $ts.emailAddress }}</template> + <template #caption>{{ $ts._forgotPassword.enterEmail }}</template> + </MkInput> + + <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton> + </div> + <div class="sub"> + <MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA> + </div> + </form> + <div v-else> + {{ $ts._forgotPassword.contactAdmin }} + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XModalWindow, + MkButton, + MkInput, + }, + + emits: ['done', 'closed'], + + data() { + return { + username: '', + email: '', + processing: false, + }; + }, + + methods: { + async onSubmit() { + this.processing = true; + await os.apiWithDialog('request-reset-password', { + username: this.username, + email: this.email, + }); + + this.$emit('done'); + this.$refs.dialog.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.bafeceda { + > .main { + padding: 24px; + } + + > .sub { + border-top: solid 0.5px var(--divider); + padding: 24px; + } +} +</style> diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/form-dialog.vue new file mode 100644 index 0000000000..172e6a5138 --- /dev/null +++ b/packages/client/src/components/form-dialog.vue @@ -0,0 +1,125 @@ +<template> +<XModalWindow ref="dialog" + :width="450" + :can-close="false" + :with-ok-button="true" + :ok-button-disabled="false" + @click="cancel()" + @ok="ok()" + @close="cancel()" + @closed="$emit('closed')" +> + <template #header> + {{ title }} + </template> + <FormBase class="xkpnjxcv"> + <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> + <FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> + <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormInput> + <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text"> + <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormInput> + <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]"> + <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormTextarea> + <FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> + <span v-text="form[item].label || item"></span> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormSwitch> + <FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option> + </FormSelect> + <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> + <template #desc><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <option v-for="item in form[item].options" :value="item.value" :key="item.value">{{ item.label }}</option> + </FormRadios> + <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step"> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormRange> + <FormButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> + <span v-text="form[item].content || item"></span> + </FormButton> + </template> + </FormBase> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import FormBase from './debobigego/base.vue'; +import FormInput from './debobigego/input.vue'; +import FormTextarea from './debobigego/textarea.vue'; +import FormSwitch from './debobigego/switch.vue'; +import FormSelect from './debobigego/select.vue'; +import FormRange from './debobigego/range.vue'; +import FormButton from './debobigego/button.vue'; +import FormRadios from './debobigego/radios.vue'; + +export default defineComponent({ + components: { + XModalWindow, + FormBase, + FormInput, + FormTextarea, + FormSwitch, + FormSelect, + FormRange, + FormButton, + FormRadios, + }, + + props: { + title: { + type: String, + required: true, + }, + form: { + type: Object, + required: true, + }, + }, + + emits: ['done'], + + data() { + return { + values: {} + }; + }, + + created() { + for (const item in this.form) { + this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null; + } + }, + + methods: { + ok() { + this.$emit('done', { + result: this.values + }); + this.$refs.dialog.close(); + }, + + cancel() { + this.$emit('done', { + canceled: true + }); + this.$refs.dialog.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.xkpnjxcv { + +} +</style> diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue new file mode 100644 index 0000000000..f2c1ead00c --- /dev/null +++ b/packages/client/src/components/form/input.vue @@ -0,0 +1,315 @@ +<template> +<div class="matxzzsk"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ inline, disabled, focused }"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import { debounce } from 'throttle-debounce'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + modelValue: { + required: true + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + step: { + required: false + }, + datalist: { + type: Array, + required: false, + }, + inline: { + type: Boolean, + required: false, + default: false + }, + debounce: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + + setup(props, context) { + const { modelValue, type, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const id = Math.random().toString(); // TODO: uuid? + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + const updated = () => { + changed.value = false; + if (type?.value === 'number') { + context.emit('update:modelValue', parseFloat(v.value)); + } else { + context.emit('update:modelValue', v.value); + } + }; + + const debouncedUpdated = debounce(1000, updated); + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + return { + id, + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + focus, + onInput, + onKeydown, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.matxzzsk { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + $height: 42px; + position: relative; + + > input { + appearance: none; + -webkit-appearance: none; + display: block; + height: $height; + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + + &:hover { + border-color: var(--inputBorderHover); + } + } + + > .prefix, + > .suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: $height; + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 6px; + } + + > .suffix { + right: 0; + padding-left: 6px; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > input { + border-color: var(--accent); + //box-shadow: 0 0 0 4px var(--focus); + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + } +} +</style> diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue new file mode 100644 index 0000000000..0f31d8fa0a --- /dev/null +++ b/packages/client/src/components/form/radio.vue @@ -0,0 +1,122 @@ +<template> +<div + class="novjtctn" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click="toggle" +> + <input type="radio" + :disabled="disabled" + > + <span class="button"> + <span></span> + </span> + <span class="label"><slot></slot></span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + modelValue: { + required: false + }, + value: { + required: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.modelValue === this.value; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:modelValue', this.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.novjtctn { + position: relative; + display: inline-block; + margin: 8px 20px 0 0; + text-align: left; + cursor: pointer; + transition: all 0.3s; + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + + &, * { + cursor: not-allowed !important; + } + } + + &.checked { + > .button { + border-color: var(--accent); + + &:after { + background-color: var(--accent); + transform: scale(1); + opacity: 1; + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: absolute; + width: 20px; + height: 20px; + background: none; + border: solid 2px var(--inputBorder); + border-radius: 100%; + transition: inherit; + + &:after { + content: ''; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); + } + } + + > .label { + margin-left: 28px; + display: block; + font-size: 16px; + line-height: 20px; + cursor: pointer; + } +} +</style> diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue new file mode 100644 index 0000000000..998a738202 --- /dev/null +++ b/packages/client/src/components/form/radios.vue @@ -0,0 +1,54 @@ +<script lang="ts"> +import { defineComponent, h } from 'vue'; +import MkRadio from './radio.vue'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + modelValue: { + required: false + }, + }, + data() { + return { + value: this.modelValue, + } + }, + watch: { + value() { + this.$emit('update:modelValue', this.value); + } + }, + render() { + let options = this.$slots.default(); + + // なぜかFragmentになることがあるため + if (options.length === 1 && options[0].props == null) options = options[0].children; + + return h('div', { + class: 'novjtcto' + }, [ + ...options.map(option => h(MkRadio, { + key: option.key, + value: option.props.value, + modelValue: this.value, + 'onUpdate:modelValue': value => this.value = value, + }, option.children)) + ]); + } +}); +</script> + +<style lang="scss"> +.novjtcto { + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } +} +</style> diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue new file mode 100644 index 0000000000..4cfe66a8fc --- /dev/null +++ b/packages/client/src/components/form/range.vue @@ -0,0 +1,139 @@ +<template> +<div class="timctyfi" :class="{ focused, disabled }"> + <div class="icon"><slot name="icon"></slot></div> + <span class="label"><slot name="label"></slot></span> + <input + type="range" + ref="input" + v-model="v" + :disabled="disabled" + :min="min" + :max="max" + :step="step" + :autofocus="autofocus" + @focus="focused = true" + @blur="focused = false" + @input="$emit('update:value', $event.target.value)" + /> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: false, + default: 0 + }, + disabled: { + type: Boolean, + required: false, + default: false + }, + min: { + type: Number, + required: false, + default: 0 + }, + max: { + type: Number, + required: false, + default: 100 + }, + step: { + type: Number, + required: false, + default: 1 + }, + autofocus: { + type: Boolean, + required: false + } + }, + data() { + return { + v: this.value, + focused: false + }; + }, + watch: { + value(v) { + this.v = parseFloat(v); + } + }, + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$refs.input.focus(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.timctyfi { + position: relative; + margin: 8px; + + > .icon { + display: inline-block; + width: 24px; + text-align: center; + } + + > .title { + pointer-events: none; + font-size: 16px; + color: var(--inputLabel); + overflow: hidden; + } + + > input { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--X10); + height: 7px; + margin: 0 8px; + outline: 0; + border: 0; + border-radius: 7px; + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + box-sizing: content-box; + } + + &::-moz-range-thumb { + -moz-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + } + } +} +</style> diff --git a/packages/client/src/components/form/section.vue b/packages/client/src/components/form/section.vue new file mode 100644 index 0000000000..8eac40a0db --- /dev/null +++ b/packages/client/src/components/form/section.vue @@ -0,0 +1,31 @@ +<template> +<div class="vrtktovh" v-size="{ max: [500] }" v-sticky-container> + <div class="label"><slot name="label"></slot></div> + <div class="main"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.vrtktovh { + border-top: solid 0.5px var(--divider); + + > .label { + font-weight: bold; + padding: 24px 0 16px 0; + } + + > .main { + margin-bottom: 32px; + } +} +</style> diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue new file mode 100644 index 0000000000..f7eb5cd14d --- /dev/null +++ b/packages/client/src/components/form/select.vue @@ -0,0 +1,312 @@ +<template> +<div class="vblkjoeq"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick" ref="container"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <select class="select" ref="inputEl" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + @focus="focused = true" + @blur="focused = false" + @input="onInput" + > + <slot></slot> + </select> + <div class="suffix" ref="suffixEl"><i class="fas fa-chevron-down"></i></div> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + modelValue: { + required: true + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['change', 'update:modelValue'], + + setup(props, context) { + const { modelValue, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + const container = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + updated(); + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + const onClick = (ev: MouseEvent) => { + focused.value = true; + + const menu = []; + let options = context.slots.default(); + + const pushOption = (option: VNode) => { + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + }; + + const scanOptions = (options: VNode[]) => { + for (const vnode of options) { + if (vnode.type === 'optgroup') { + const optgroup = vnode; + menu.push({ + type: 'label', + text: optgroup.props.label, + }); + scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + scanOptions(fragment.children); + } else { + const option = vnode; + pushOption(option); + } + } + }; + + scanOptions(options); + + os.popupMenu(menu, container.value, { + width: container.value.offsetWidth, + }).then(() => { + focused.value = false; + }); + }; + + return { + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + container, + focus, + onInput, + onClick, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.vblkjoeq { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + $height: 42px; + position: relative; + cursor: pointer; + + &:hover { + > .select { + border-color: var(--inputBorderHover); + } + } + + > .select { + appearance: none; + -webkit-appearance: none; + display: block; + height: $height; + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--inputBorder); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + cursor: pointer; + transition: border-color 0.1s ease-out; + pointer-events: none; + } + + > .prefix, + > .suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: $height; + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 6px; + } + + > .suffix { + right: 0; + padding-left: 6px; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.focused { + > select { + border-color: var(--accent); + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + } +} +</style> diff --git a/packages/client/src/components/form/slot.vue b/packages/client/src/components/form/slot.vue new file mode 100644 index 0000000000..8580c1307d --- /dev/null +++ b/packages/client/src/components/form/slot.vue @@ -0,0 +1,50 @@ +<template> +<div class="adhpbeou"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="content"> + <slot></slot> + </div> + <div class="caption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.adhpbeou { + margin: 1.5em 0; + + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .content { + position: relative; + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; + } +} +</style> diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue new file mode 100644 index 0000000000..85f8b7c870 --- /dev/null +++ b/packages/client/src/components/form/switch.vue @@ -0,0 +1,150 @@ +<template> +<div + class="ziffeoms" + :class="{ disabled, checked }" + role="switch" + :aria-checked="checked" + :aria-disabled="disabled" + @click.prevent="toggle" +> + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> + <span class="handle"></span> + </span> + <span class="label"> + <span><slot></slot></span> + <p><slot name="caption"></slot></p> + </span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + modelValue: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.modelValue; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:modelValue', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ziffeoms { + position: relative; + display: flex; + cursor: pointer; + transition: all 0.3s; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 0; + width: 36px; + height: 26px; + background: var(--switchBg); + outline: none; + border-radius: 999px; + transition: inherit; + + > .handle { + position: absolute; + top: 0; + bottom: 0; + left: 5px; + margin: auto 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 16px; + height: 16px; + background-color: #fff; + } + } + + > .label { + margin-left: 16px; + margin-top: 2px; + display: block; + cursor: pointer; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + + > p { + margin: 0; + color: var(--fgTransparentWeak); + font-size: 90%; + } + } + + &:hover { + > .button { + background-color: var(--accentedBg); + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent); + border-color: var(--accent); + + > .handle { + transform: translateX(10px); + } + } + } +} +</style> diff --git a/packages/client/src/components/form/textarea.vue b/packages/client/src/components/form/textarea.vue new file mode 100644 index 0000000000..fdb24f1e2b --- /dev/null +++ b/packages/client/src/components/form/textarea.vue @@ -0,0 +1,252 @@ +<template> +<div class="adhpbeos"> + <div class="label" @click="focus"><slot name="label"></slot></div> + <div class="input" :class="{ disabled, focused, tall, pre }"> + <textarea ref="inputEl" + :class="{ code, _monospace: code }" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + ></textarea> + </div> + <div class="caption"><slot name="caption"></slot></div> + + <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import { debounce } from 'throttle-debounce'; + +export default defineComponent({ + components: { + MkButton, + }, + + props: { + modelValue: { + required: true + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + code: { + type: Boolean, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + debounce: { + type: Boolean, + required: false, + default: false + }, + manualSave: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['change', 'keydown', 'enter', 'update:modelValue'], + + setup(props, context) { + const { modelValue, autofocus } = toRefs(props); + const v = ref(modelValue.value); + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + const updated = () => { + changed.value = false; + context.emit('update:modelValue', v.value); + }; + + const debouncedUpdated = debounce(1000, updated); + + watch(modelValue, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); + }); + + return { + v, + focused, + invalid, + changed, + filled, + inputEl, + focus, + onInput, + onKeydown, + updated, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.adhpbeos { + > .label { + font-size: 0.85em; + padding: 0 0 8px 12px; + user-select: none; + + &:empty { + display: none; + } + } + + > .caption { + font-size: 0.8em; + padding: 8px 0 0 12px; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } + } + + > .input { + position: relative; + + > textarea { + appearance: none; + -webkit-appearance: none; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 0.5px var(--inputBorder); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + + &:hover { + border-color: var(--inputBorderHover); + } + } + + &.focused { + > textarea { + border-color: var(--accent); + } + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + &.tall { + > textarea { + min-height: 200px; + } + } + + &.pre { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/packages/client/src/components/formula-core.vue b/packages/client/src/components/formula-core.vue new file mode 100644 index 0000000000..cf8dee872b --- /dev/null +++ b/packages/client/src/components/formula-core.vue @@ -0,0 +1,34 @@ + +<template> +<div v-if="block" v-html="compiledFormula"></div> +<span v-else v-html="compiledFormula"></span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as katex from 'katex';import * as os from '@/os'; + +export default defineComponent({ + props: { + formula: { + type: String, + required: true + }, + block: { + type: Boolean, + required: true + } + }, + computed: { + compiledFormula(): any { + return katex.renderToString(this.formula, { + throwOnError: false + } as any); + } + } +}); +</script> + +<style> +@import "../../node_modules/katex/dist/katex.min.css"; +</style> diff --git a/packages/client/src/components/formula.vue b/packages/client/src/components/formula.vue new file mode 100644 index 0000000000..fbb40bace7 --- /dev/null +++ b/packages/client/src/components/formula.vue @@ -0,0 +1,23 @@ +<template> +<XFormula :formula="formula" :block="block" /> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({ + components: { + XFormula: defineAsyncComponent(() => import('./formula-core.vue')) + }, + props: { + formula: { + type: String, + required: true + }, + block: { + type: Boolean, + required: true + } + } +}); +</script> diff --git a/packages/client/src/components/gallery-post-preview.vue b/packages/client/src/components/gallery-post-preview.vue new file mode 100644 index 0000000000..8245902976 --- /dev/null +++ b/packages/client/src/components/gallery-post-preview.vue @@ -0,0 +1,126 @@ +<template> +<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1"> + <div class="thumbnail"> + <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> + </div> + <article> + <header> + <MkAvatar :user="post.user" class="avatar"/> + </header> + <footer> + <span class="title">{{ post.title }}</span> + </footer> + </article> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { userName } from '@/filters/user'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + ImgWithBlurhash + }, + props: { + post: { + type: Object, + required: true + }, + }, + methods: { + userName + } +}); +</script> + +<style lang="scss" scoped> +.ttasepnz { + display: block; + position: relative; + height: 200px; + + &:hover { + text-decoration: none; + color: var(--accent); + + > .thumbnail { + transform: scale(1.1); + } + + > article { + > footer { + &:before { + opacity: 1; + } + } + } + } + + > .thumbnail { + width: 100%; + height: 100%; + position: absolute; + transition: all 0.5s ease; + + > .img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + > article { + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + + > header { + position: absolute; + top: 0; + width: 100%; + padding: 12px; + box-sizing: border-box; + display: flex; + + > .avatar { + margin-left: auto; + width: 32px; + height: 32px; + } + } + + > footer { + position: absolute; + bottom: 0; + width: 100%; + padding: 16px; + box-sizing: border-box; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + + &:before { + content: ""; + display: block; + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(rgba(0, 0, 0, 0.4), transparent); + opacity: 0; + transition: opacity 0.5s ease; + } + + > .title { + font-weight: bold; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue new file mode 100644 index 0000000000..5db61203c6 --- /dev/null +++ b/packages/client/src/components/global/a.vue @@ -0,0 +1,138 @@ +<template> +<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu"> + <slot></slot> +</a> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { router } from '@/router'; +import { url } from '@/config'; +import { popout } from '@/scripts/popout'; +import { ColdDeviceStorage } from '@/store'; + +export default defineComponent({ + inject: { + navHook: { + default: null + }, + sideViewHook: { + default: null + } + }, + + props: { + to: { + type: String, + required: true, + }, + activeClass: { + type: String, + required: false, + }, + behavior: { + type: String, + required: false, + }, + }, + + computed: { + active() { + if (this.activeClass == null) return false; + const resolved = router.resolve(this.to); + if (resolved.path == this.$route.path) return true; + if (resolved.name == null) return false; + if (this.$route.name == null) return false; + return resolved.name == this.$route.name; + } + }, + + methods: { + onContextmenu(e) { + if (window.getSelection().toString() !== '') return; + os.contextMenu([{ + type: 'label', + text: this.to, + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.to); + } + }, this.sideViewHook ? { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.sideViewHook(this.to); + } + } : undefined, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: () => { + this.$router.push(this.to); + } + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.to, '_blank'); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(`${url}${this.to}`); + } + }], e); + }, + + window() { + os.pageWindow(this.to); + }, + + modalWindow() { + os.modalPageWindow(this.to); + }, + + popout() { + popout(this.to); + }, + + nav() { + if (this.behavior === 'browser') { + location.href = this.to; + return; + } + + if (this.to.startsWith('/my/messaging')) { + if (ColdDeviceStorage.get('chatOpenBehavior') === 'window') return this.window(); + if (ColdDeviceStorage.get('chatOpenBehavior') === 'popout') return this.popout(); + } + + if (this.behavior) { + if (this.behavior === 'window') { + return this.window(); + } else if (this.behavior === 'modalWindow') { + return this.modalWindow(); + } + } + + if (this.navHook) { + this.navHook(this.to); + } else { + if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') { + return this.sideViewHook(this.to); + } + + if (this.$router.currentRoute.value.path === this.to) { + window.scroll({ top: 0, behavior: 'smooth' }); + } else { + this.$router.push(this.to); + } + } + } + } +}); +</script> diff --git a/packages/client/src/components/global/acct.vue b/packages/client/src/components/global/acct.vue new file mode 100644 index 0000000000..b0c41c99c0 --- /dev/null +++ b/packages/client/src/components/global/acct.vue @@ -0,0 +1,38 @@ +<template> +<span class="mk-acct"> + <span class="name">@{{ user.username }}</span> + <span class="host" v-if="user.host || detail || $store.state.showFullAcct">@{{ user.host || host }}</span> +</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import { host } from '@/config'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + detail: { + type: Boolean, + default: false + }, + }, + data() { + return { + host: toUnicode(host), + }; + } +}); +</script> + +<style lang="scss" scoped> +.mk-acct { + > .host { + opacity: 0.5; + } +} +</style> diff --git a/packages/client/src/components/global/ad.vue b/packages/client/src/components/global/ad.vue new file mode 100644 index 0000000000..71cb16740c --- /dev/null +++ b/packages/client/src/components/global/ad.vue @@ -0,0 +1,200 @@ +<template> +<div class="qiivuoyo" v-if="ad"> + <div class="main" :class="ad.place" v-if="!showMenu"> + <a :href="ad.url" target="_blank"> + <img :src="ad.imageUrl"> + <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button> + </a> + </div> + <div class="menu" v-else> + <div class="body"> + <div>Ads by {{ host }}</div> + <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>--> + <MkButton v-if="ad.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton> + <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button> + </div> + </div> +</div> +<div v-else></div> +</template> + +<script lang="ts"> +import { defineComponent, ref } from 'vue'; +import { Instance, instance } from '@/instance'; +import { host } from '@/config'; +import MkButton from '@/components/ui/button.vue'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + prefer: { + type: Array, + required: true + }, + specify: { + type: Object, + required: false + }, + }, + + setup(props) { + const showMenu = ref(false); + const toggleMenu = () => { + showMenu.value = !showMenu.value; + }; + + const choseAd = (): Instance['ads'][number] | null => { + if (props.specify) { + return props.specify as Instance['ads'][number]; + } + + const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { + ...ad, + ratio: 0 + } : ad); + + let ads = allAds.filter(ad => props.prefer.includes(ad.place)); + + if (ads.length === 0) { + ads = allAds.filter(ad => ad.place === 'square'); + } + + const lowPriorityAds = ads.filter(ad => ad.ratio === 0); + ads = ads.filter(ad => ad.ratio !== 0); + + if (ads.length === 0) { + if (lowPriorityAds.length !== 0) { + return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)]; + } else { + return null; + } + } + + const totalFactor = ads.reduce((a, b) => a + b.ratio, 0); + const r = Math.random() * totalFactor; + + let stackedFactor = 0; + for (const ad of ads) { + if (r >= stackedFactor && r <= stackedFactor + ad.ratio) { + return ad; + } else { + stackedFactor += ad.ratio; + } + } + + return null; + }; + + const chosen = ref(choseAd()); + + const reduceFrequency = () => { + if (chosen.value == null) return; + if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; + defaultStore.push('mutedAds', chosen.value.id); + os.success(); + chosen.value = choseAd(); + showMenu.value = false; + }; + + return { + ad: chosen, + showMenu, + toggleMenu, + host, + reduceFrequency, + }; + } +}); +</script> + +<style lang="scss" scoped> +.qiivuoyo { + background-size: auto auto; + background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px ); + + > .main { + text-align: center; + + > a { + display: inline-block; + position: relative; + vertical-align: bottom; + + &:hover { + > img { + filter: contrast(120%); + } + } + + > img { + display: block; + object-fit: contain; + margin: auto; + } + + > .menu { + position: absolute; + top: 0; + right: 0; + background: var(--panel); + } + } + + &.square { + > a , + > a > img { + max-width: min(300px, 100%); + max-height: 300px; + } + } + + &.horizontal { + padding: 8px; + + > a , + > a > img { + max-width: min(600px, 100%); + max-height: 80px; + } + } + + &.horizontal-big { + padding: 8px; + + > a , + > a > img { + max-width: min(600px, 100%); + max-height: 250px; + } + } + + &.vertical { + > a , + > a > img { + max-width: min(100px, 100%); + } + } + } + + > .menu { + padding: 8px; + text-align: center; + + > .body { + padding: 8px; + margin: 0 auto; + max-width: 400px; + border: solid 1px var(--divider); + + > .button { + margin: 8px auto; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/avatar.vue new file mode 100644 index 0000000000..e509e893da --- /dev/null +++ b/packages/client/src/components/global/avatar.vue @@ -0,0 +1,163 @@ +<template> +<span class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> + <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> +</span> +<MkA class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> + <img class="inner" :src="url" decoding="async"/> + <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; +import { acct, userPage } from '@/filters/user'; +import MkUserOnlineIndicator from '@/components/user-online-indicator.vue'; + +export default defineComponent({ + components: { + MkUserOnlineIndicator + }, + props: { + user: { + type: Object, + required: true + }, + target: { + required: false, + default: null + }, + disableLink: { + required: false, + default: false + }, + disablePreview: { + required: false, + default: false + }, + showIndicator: { + required: false, + default: false + } + }, + emits: ['click'], + computed: { + cat(): boolean { + return this.user.isCat; + }, + url(): string { + return this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(this.user.avatarUrl) + : this.user.avatarUrl; + }, + }, + watch: { + 'user.avatarBlurhash'() { + if (this.$el == null) return; + this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); + } + }, + mounted() { + this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); + }, + methods: { + onClick(e) { + this.$emit('click', e); + }, + acct, + userPage + } +}); +</script> + +<style lang="scss" scoped> +@keyframes earwiggleleft { + from { transform: rotate(37.6deg) skew(30deg); } + 25% { transform: rotate(10deg) skew(30deg); } + 50% { transform: rotate(20deg) skew(30deg); } + 75% { transform: rotate(0deg) skew(30deg); } + to { transform: rotate(37.6deg) skew(30deg); } +} + +@keyframes earwiggleright { + from { transform: rotate(-37.6deg) skew(-30deg); } + 30% { transform: rotate(-10deg) skew(-30deg); } + 55% { transform: rotate(-20deg) skew(-30deg); } + 75% { transform: rotate(0deg) skew(-30deg); } + to { transform: rotate(-37.6deg) skew(-30deg); } +} + +.eiwwqkts { + position: relative; + display: inline-block; + vertical-align: bottom; + flex-shrink: 0; + border-radius: 100%; + line-height: 16px; + + > .inner { + position: absolute; + bottom: 0; + left: 0; + right: 0; + top: 0; + border-radius: 100%; + z-index: 1; + overflow: hidden; + object-fit: cover; + width: 100%; + height: 100%; + } + + > .indicator { + position: absolute; + z-index: 1; + bottom: 0; + left: 0; + width: 20%; + height: 20%; + } + + &.square { + border-radius: 20%; + + > .inner { + border-radius: 20%; + } + } + + &.cat { + &:before, &:after { + background: #df548f; + border: solid 4px currentColor; + box-sizing: border-box; + content: ''; + display: inline-block; + height: 50%; + width: 50%; + } + + &:before { + border-radius: 0 75% 75%; + transform: rotate(37.5deg) skew(30deg); + } + + &:after { + border-radius: 75% 0 75% 75%; + transform: rotate(-37.5deg) skew(-30deg); + } + + &:hover { + &:before { + animation: earwiggleleft 1s infinite; + } + + &:after { + animation: earwiggleright 1s infinite; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/ellipsis.vue b/packages/client/src/components/global/ellipsis.vue new file mode 100644 index 0000000000..0a46f486d6 --- /dev/null +++ b/packages/client/src/components/global/ellipsis.vue @@ -0,0 +1,34 @@ +<template> + <span class="mk-ellipsis"> + <span>.</span><span>.</span><span>.</span> + </span> +</template> + +<style lang="scss" scoped> +.mk-ellipsis { + > span { + animation: ellipsis 1.4s infinite ease-in-out both; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.16s; + } + + &:nth-child(3) { + animation-delay: 0.32s; + } + } +} + +@keyframes ellipsis { + 0%, 80%, 100% { + opacity: 1; + } + 40% { + opacity: 0; + } +} +</style> diff --git a/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/emoji.vue new file mode 100644 index 0000000000..67a3dea2c5 --- /dev/null +++ b/packages/client/src/components/global/emoji.vue @@ -0,0 +1,125 @@ +<template> +<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/> +<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/> +<span v-else-if="char && useOsNativeEmojis">{{ char }}</span> +<span v-else>{{ emoji }}</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { twemojiSvgBase } from '@/scripts/twemoji-base'; + +export default defineComponent({ + props: { + emoji: { + type: String, + required: true + }, + normal: { + type: Boolean, + required: false, + default: false + }, + noStyle: { + type: Boolean, + required: false, + default: false + }, + customEmojis: { + required: false + }, + isReaction: { + type: Boolean, + default: false + }, + }, + + data() { + return { + url: null, + char: null, + customEmoji: null + } + }, + + computed: { + isCustom(): boolean { + return this.emoji.startsWith(':'); + }, + + alt(): string { + return this.customEmoji ? `:${this.customEmoji.name}:` : this.char; + }, + + useOsNativeEmojis(): boolean { + return this.$store.state.useOsNativeEmojis && !this.isReaction; + }, + + ce() { + return this.customEmojis || this.$instance?.emojis || []; + } + }, + + watch: { + ce: { + handler() { + if (this.isCustom) { + const customEmoji = this.ce.find(x => x.name === this.emoji.substr(1, this.emoji.length - 2)); + if (customEmoji) { + this.customEmoji = customEmoji; + this.url = this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(customEmoji.url) + : customEmoji.url; + } + } + }, + immediate: true + }, + }, + + created() { + if (!this.isCustom) { + this.char = this.emoji; + } + + if (this.char) { + let codes = Array.from(this.char).map(x => x.codePointAt(0).toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); + + this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`; + } + }, +}); +</script> + +<style lang="scss" scoped> +.mk-emoji { + height: 1.25em; + vertical-align: -0.25em; + + &.custom { + height: 2.5em; + vertical-align: middle; + transition: transform 0.2s ease; + + &:hover { + transform: scale(1.2); + } + + &.normal { + height: 1.25em; + vertical-align: -0.25em; + + &:hover { + transform: none; + } + } + } + + &.noStyle { + height: auto !important; + } +} +</style> diff --git a/packages/client/src/components/global/error.vue b/packages/client/src/components/global/error.vue new file mode 100644 index 0000000000..8ce5d16ac6 --- /dev/null +++ b/packages/client/src/components/global/error.vue @@ -0,0 +1,46 @@ +<template> +<transition :name="$store.state.animation ? 'zoom' : ''" appear> + <div class="mjndxjcg"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</p> + <MkButton @click="() => $emit('retry')" class="button">{{ $ts.retry }}</MkButton> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + data() { + return { + }; + }, +}); +</script> + +<style lang="scss" scoped> +.mjndxjcg { + padding: 32px; + text-align: center; + + > p { + margin: 0 0 8px 0; + } + + > .button { + margin: 0 auto; + } + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} +</style> diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue new file mode 100644 index 0000000000..7d5e426f2b --- /dev/null +++ b/packages/client/src/components/global/header.vue @@ -0,0 +1,360 @@ +<template> +<div class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick" ref="el"> + <template v-if="info"> + <div class="titleContainer" @click="showTabsPopup" v-if="!hideTitle"> + <MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> + <i v-else-if="info.icon" class="icon" :class="info.icon"></i> + + <div class="title"> + <MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/> + <div v-else-if="info.title" class="title">{{ info.title }}</div> + <div class="subtitle" v-if="!narrow && info.subtitle"> + {{ info.subtitle }} + </div> + <div class="subtitle activeTab" v-if="narrow && hasTabs"> + {{ info.tabs.find(tab => tab.active)?.title }} + <i class="chevron fas fa-chevron-down"></i> + </div> + </div> + </div> + <div class="tabs" v-if="!narrow || hideTitle"> + <button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + </div> + </template> + <div class="buttons right"> + <template v-if="info && info.actions && !narrow"> + <template v-for="action in info.actions"> + <MkButton class="fullButton" v-if="action.asFullButton" @click.stop="action.handler" primary><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button> + </template> + </template> + <button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { url } from '@/config'; +import { scrollToTop } from '@/scripts/scroll'; +import MkButton from '@/components/ui/button.vue'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + info: { + type: Object as PropType<{ + actions?: {}[]; + tabs?: {}[]; + }>, + required: true + }, + menu: { + required: false + }, + thin: { + required: false, + default: false + }, + }, + + setup(props) { + const el = ref<HTMLElement>(null); + const bg = ref(null); + const narrow = ref(false); + const height = ref(0); + const hasTabs = computed(() => { + return props.info.tabs && props.info.tabs.length > 0; + }); + const shouldShowMenu = computed(() => { + if (props.info == null) return false; + if (props.info.actions != null && narrow.value) return true; + if (props.info.menu != null) return true; + if (props.info.share != null) return true; + if (props.menu != null) return true; + return false; + }); + + const share = () => { + navigator.share({ + url: url + props.info.path, + ...props.info.share, + }); + }; + + const showMenu = (ev: MouseEvent) => { + let menu = props.info.menu ? props.info.menu() : []; + if (narrow.value && props.info.actions) { + menu = [...props.info.actions.map(x => ({ + text: x.text, + icon: x.icon, + action: x.handler + })), menu.length > 0 ? null : undefined, ...menu]; + } + if (props.info.share) { + if (menu.length > 0) menu.push(null); + menu.push({ + text: i18n.locale.share, + icon: 'fas fa-share-alt', + action: share + }); + } + if (props.menu) { + if (menu.length > 0) menu.push(null); + menu = menu.concat(props.menu); + } + popupMenu(menu, ev.currentTarget || ev.target); + }; + + const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs.value) return; + if (!narrow.value) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.info.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + action: tab.onClick, + })); + popupMenu(menu, ev.currentTarget || ev.target); + }; + + const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); + }; + + const onClick = () => { + scrollToTop(el.value, { behavior: 'smooth' }); + }; + + const calcBg = () => { + const rawBg = props.info?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); + }; + + onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); + }); + + if (el.value.parentElement) { + narrow.value = el.value.parentElement.offsetWidth < 500; + const ro = new ResizeObserver((entries, observer) => { + if (el.value) { + narrow.value = el.value.parentElement.offsetWidth < 500; + } + }); + ro.observe(el.value.parentElement); + onUnmounted(() => { + ro.disconnect(); + }); + } + }); + + return { + el, + bg, + narrow, + height, + hasTabs, + shouldShowMenu, + share, + showMenu, + showTabsPopup, + preventDrag, + onClick, + hideTitle: inject('shouldOmitHeaderTitle', false), + thin_: props.thin || inject('shouldHeaderThin', false) + }; + }, +}); +</script> + +<style lang="scss" scoped> +.fdidabkb { + --height: 60px; + display: flex; + position: sticky; + top: var(--stickyTop, 0); + z-index: 1000; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-bottom: solid 0.5px var(--divider); + + &.thin { + --height: 50px; + + > .buttons { + > .button { + font-size: 0.9em; + } + } + } + + &.slim { + text-align: center; + + > .titleContainer { + flex: 1; + margin: 0 auto; + margin-left: var(--height); + + > *:first-child { + margin-left: auto; + } + + > *:last-child { + margin-right: auto; + } + } + } + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + + &:after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + right: 0; + margin: 0 auto; + width: 100%; + height: 3px; + background: var(--accent); + } + } + + > .icon + .title { + margin-left: 8px; + } + } + } +} +</style> diff --git a/packages/client/src/components/global/i18n.ts b/packages/client/src/components/global/i18n.ts new file mode 100644 index 0000000000..abf0c96856 --- /dev/null +++ b/packages/client/src/components/global/i18n.ts @@ -0,0 +1,42 @@ +import { h, defineComponent } from 'vue'; + +export default defineComponent({ + props: { + src: { + type: String, + required: true, + }, + tag: { + type: String, + required: false, + default: 'span', + }, + textTag: { + type: String, + required: false, + default: null, + }, + }, + render() { + let str = this.src; + const parsed = [] as (string | { arg: string; })[]; + while (true) { + const nextBracketOpen = str.indexOf('{'); + const nextBracketClose = str.indexOf('}'); + + if (nextBracketOpen === -1) { + parsed.push(str); + break; + } else { + if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); + parsed.push({ + arg: str.substring(nextBracketOpen + 1, nextBracketClose) + }); + } + + str = str.substr(nextBracketClose + 1); + } + + return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]())); + } +}); diff --git a/packages/client/src/components/global/loading.vue b/packages/client/src/components/global/loading.vue new file mode 100644 index 0000000000..7bde53c12e --- /dev/null +++ b/packages/client/src/components/global/loading.vue @@ -0,0 +1,92 @@ +<template> +<div class="yxspomdl" :class="{ inline, colored, mini }"> + <div class="ring"></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + inline: { + type: Boolean, + required: false, + default: false + }, + colored: { + type: Boolean, + required: false, + default: true + }, + mini: { + type: Boolean, + required: false, + default: false + }, + } +}); +</script> + +<style lang="scss" scoped> +@keyframes ring { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.yxspomdl { + padding: 32px; + text-align: center; + cursor: wait; + + --size: 48px; + + &.colored { + color: var(--accent); + } + + &.inline { + display: inline; + padding: 0; + --size: 32px; + } + + &.mini { + padding: 16px; + --size: 32px; + } + + > .ring { + position: relative; + display: inline-block; + vertical-align: middle; + + &:before, + &:after { + content: " "; + display: block; + box-sizing: border-box; + width: var(--size); + height: var(--size); + border-radius: 50%; + border: solid 4px; + } + + &:before { + border-color: currentColor; + opacity: 0.3; + } + + &:after { + position: absolute; + top: 0; + border-color: currentColor transparent transparent transparent; + animation: ring 0.5s linear infinite; + } + } +} +</style> diff --git a/packages/client/src/components/global/misskey-flavored-markdown.vue b/packages/client/src/components/global/misskey-flavored-markdown.vue new file mode 100644 index 0000000000..ab20404909 --- /dev/null +++ b/packages/client/src/components/global/misskey-flavored-markdown.vue @@ -0,0 +1,157 @@ +<template> +<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MfmCore from '@/components/mfm'; + +export default defineComponent({ + components: { + MfmCore + } +}); +</script> + +<style lang="scss"> +._mfm_blur_ { + filter: blur(6px); + transition: filter 0.3s; + + &:hover { + filter: blur(0px); + } +} + +@keyframes mfm-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes mfm-spinX { + 0% { transform: perspective(128px) rotateX(0deg); } + 100% { transform: perspective(128px) rotateX(360deg); } +} + +@keyframes mfm-spinY { + 0% { transform: perspective(128px) rotateY(0deg); } + 100% { transform: perspective(128px) rotateY(360deg); } +} + +@keyframes mfm-jump { + 0% { transform: translateY(0); } + 25% { transform: translateY(-16px); } + 50% { transform: translateY(0); } + 75% { transform: translateY(-8px); } + 100% { transform: translateY(0); } +} + +@keyframes mfm-bounce { + 0% { transform: translateY(0) scale(1, 1); } + 25% { transform: translateY(-16px) scale(1, 1); } + 50% { transform: translateY(0) scale(1, 1); } + 75% { transform: translateY(0) scale(1.5, 0.75); } + 100% { transform: translateY(0) scale(1, 1); } +} + +// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-twitch { + 0% { transform: translate(7px, -2px) } + 5% { transform: translate(-3px, 1px) } + 10% { transform: translate(-7px, -1px) } + 15% { transform: translate(0px, -1px) } + 20% { transform: translate(-8px, 6px) } + 25% { transform: translate(-4px, -3px) } + 30% { transform: translate(-4px, -6px) } + 35% { transform: translate(-8px, -8px) } + 40% { transform: translate(4px, 6px) } + 45% { transform: translate(-3px, 1px) } + 50% { transform: translate(2px, -10px) } + 55% { transform: translate(-7px, 0px) } + 60% { transform: translate(-2px, 4px) } + 65% { transform: translate(3px, -8px) } + 70% { transform: translate(6px, 7px) } + 75% { transform: translate(-7px, -2px) } + 80% { transform: translate(-7px, -8px) } + 85% { transform: translate(9px, 3px) } + 90% { transform: translate(-3px, -2px) } + 95% { transform: translate(-10px, 2px) } + 100% { transform: translate(-2px, -6px) } +} + +// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; +// let css = ''; +// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } +@keyframes mfm-shake { + 0% { transform: translate(-3px, -1px) rotate(-8deg) } + 5% { transform: translate(0px, -1px) rotate(-10deg) } + 10% { transform: translate(1px, -3px) rotate(0deg) } + 15% { transform: translate(1px, 1px) rotate(11deg) } + 20% { transform: translate(-2px, 1px) rotate(1deg) } + 25% { transform: translate(-1px, -2px) rotate(-2deg) } + 30% { transform: translate(-1px, 2px) rotate(-3deg) } + 35% { transform: translate(2px, 1px) rotate(6deg) } + 40% { transform: translate(-2px, -3px) rotate(-9deg) } + 45% { transform: translate(0px, -1px) rotate(-12deg) } + 50% { transform: translate(1px, 2px) rotate(10deg) } + 55% { transform: translate(0px, -3px) rotate(8deg) } + 60% { transform: translate(1px, -1px) rotate(8deg) } + 65% { transform: translate(0px, -1px) rotate(-7deg) } + 70% { transform: translate(-1px, -3px) rotate(6deg) } + 75% { transform: translate(0px, -2px) rotate(4deg) } + 80% { transform: translate(-2px, -1px) rotate(3deg) } + 85% { transform: translate(1px, -3px) rotate(-10deg) } + 90% { transform: translate(1px, 0px) rotate(3deg) } + 95% { transform: translate(-2px, 0px) rotate(-3deg) } + 100% { transform: translate(2px, 1px) rotate(2deg) } +} + +@keyframes mfm-rubberBand { + from { transform: scale3d(1, 1, 1); } + 30% { transform: scale3d(1.25, 0.75, 1); } + 40% { transform: scale3d(0.75, 1.25, 1); } + 50% { transform: scale3d(1.15, 0.85, 1); } + 65% { transform: scale3d(0.95, 1.05, 1); } + 75% { transform: scale3d(1.05, 0.95, 1); } + to { transform: scale3d(1, 1, 1); } +} + +@keyframes mfm-rainbow { + 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } + 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } +} +</style> + +<style lang="scss" scoped> +.havbbuyv { + white-space: pre-wrap; + + &.nowrap { + white-space: pre; + word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html + overflow: hidden; + text-overflow: ellipsis; + } + + ::v-deep(.quote) { + display: block; + margin: 8px; + padding: 6px 0 6px 12px; + color: var(--fg); + border-left: solid 3px var(--fg); + opacity: 0.7; + } + + ::v-deep(pre) { + font-size: 0.8em; + } + + > ::v-deep(code) { + font-size: 0.8em; + word-break: break-all; + padding: 4px 6px; + } +} +</style> diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue new file mode 100644 index 0000000000..1129d54c71 --- /dev/null +++ b/packages/client/src/components/global/spacer.vue @@ -0,0 +1,76 @@ +<template> +<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }"> + <div ref="content" :class="$style.content"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; + +export default defineComponent({ + props: { + contentMax: { + type: Number, + required: false, + default: null, + } + }, + + setup(props, context) { + let ro: ResizeObserver; + const root = ref<HTMLElement>(null); + const content = ref<HTMLElement>(null); + const margin = ref(0); + const adjust = (rect: { width: number; height: number; }) => { + if (rect.width > (props.contentMax || 500)) { + margin.value = 32; + } else { + margin.value = 12; + } + }; + + onMounted(() => { + ro = new ResizeObserver((entries) => { + /* iOSが対応していない + adjust({ + width: entries[0].borderBoxSize[0].inlineSize, + height: entries[0].borderBoxSize[0].blockSize, + }); + */ + adjust({ + width: root.value.offsetWidth, + height: root.value.offsetHeight, + }); + }); + ro.observe(root.value); + + if (props.contentMax) { + content.value.style.maxWidth = `${props.contentMax}px`; + } + }); + + onUnmounted(() => { + ro.disconnect(); + }); + + return { + root, + content, + margin, + }; + }, +}); +</script> + +<style lang="scss" module> +.root { + box-sizing: border-box; + width: 100%; +} + +.content { + margin: 0 auto; +} +</style> diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue new file mode 100644 index 0000000000..859b2c1d73 --- /dev/null +++ b/packages/client/src/components/global/sticky-container.vue @@ -0,0 +1,74 @@ +<template> +<div ref="rootEl"> + <slot name="header"></slot> + <div ref="bodyEl"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; + +export default defineComponent({ + props: { + autoSticky: { + type: Boolean, + required: false, + default: false, + }, + }, + + setup(props, context) { + const rootEl = ref<HTMLElement>(null); + const bodyEl = ref<HTMLElement>(null); + + const calc = () => { + const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; + + const header = rootEl.value.children[0]; + if (header === bodyEl.value) { + bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); + } else { + bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); + + if (props.autoSticky) { + header.style.setProperty('--stickyTop', currentStickyTop); + header.style.position = 'sticky'; + header.style.top = 'var(--stickyTop)'; + header.style.zIndex = '1'; + } + } + }; + + onMounted(() => { + calc(); + + const observer = new MutationObserver(() => { + setTimeout(() => { + calc(); + }, 100); + }); + + observer.observe(rootEl.value, { + attributes: false, + childList: true, + subtree: false, + }); + + onUnmounted(() => { + observer.disconnect(); + }); + }); + + return { + rootEl, + bodyEl, + }; + }, +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue new file mode 100644 index 0000000000..6a330a2307 --- /dev/null +++ b/packages/client/src/components/global/time.vue @@ -0,0 +1,73 @@ +<template> +<time :title="absolute"> + <template v-if="mode == 'relative'">{{ relative }}</template> + <template v-else-if="mode == 'absolute'">{{ absolute }}</template> + <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template> +</time> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + time: { + type: [Date, String], + required: true + }, + mode: { + type: String, + default: 'relative' + } + }, + data() { + return { + tickId: null, + now: new Date() + }; + }, + computed: { + _time(): Date { + return typeof this.time == 'string' ? new Date(this.time) : this.time; + }, + absolute(): string { + return this._time.toLocaleString(); + }, + relative(): string { + const time = this._time; + const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; + return ( + ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) : + ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) : + ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) : + ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) : + ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) : + ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? this.$ts._ago.justNow : + ago < -1 ? this.$ts._ago.future : + this.$ts._ago.unknown); + } + }, + created() { + if (this.mode == 'relative' || this.mode == 'detail') { + this.tickId = window.requestAnimationFrame(this.tick); + } + }, + unmounted() { + if (this.mode === 'relative' || this.mode === 'detail') { + window.clearTimeout(this.tickId); + } + }, + methods: { + tick() { + // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する + this.now = new Date(); + + this.tickId = setTimeout(() => { + window.requestAnimationFrame(this.tick); + }, 10000); + } + } +}); +</script> diff --git a/packages/client/src/components/global/url.vue b/packages/client/src/components/global/url.vue new file mode 100644 index 0000000000..092fe6620c --- /dev/null +++ b/packages/client/src/components/global/url.vue @@ -0,0 +1,142 @@ +<template> +<component :is="self ? 'MkA' : 'a'" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" + @mouseover="onMouseover" + @mouseleave="onMouseleave" + @contextmenu.stop="() => {}" +> + <template v-if="!self"> + <span class="schema">{{ schema }}//</span> + <span class="hostname">{{ hostname }}</span> + <span class="port" v-if="port != ''">:{{ port }}</span> + </template> + <template v-if="pathname === '/' && self"> + <span class="self">{{ hostname }}</span> + </template> + <span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span> + <span class="query">{{ query }}</span> + <span class="hash">{{ hash }}</span> + <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> +</component> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode as decodePunycode } from 'punycode/'; +import { url as local } from '@/config'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + url: { + type: String, + required: true, + }, + rel: { + type: String, + required: false, + } + }, + data() { + const self = this.url.startsWith(local); + return { + local, + schema: null as string | null, + hostname: null as string | null, + port: null as string | null, + pathname: null as string | null, + query: null as string | null, + hash: null as string | null, + self: self, + attr: self ? 'to' : 'href', + target: self ? null : '_blank', + showTimer: null, + hideTimer: null, + checkTimer: null, + close: null, + }; + }, + created() { + const url = new URL(this.url); + this.schema = url.protocol; + this.hostname = decodePunycode(url.hostname); + this.port = url.port; + this.pathname = decodeURIComponent(url.pathname); + this.query = decodeURIComponent(url.search); + this.hash = decodeURIComponent(url.hash); + }, + methods: { + async showPreview() { + if (!document.body.contains(this.$el)) return; + if (this.close) return; + + const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), { + url: this.url, + source: this.$el + }); + + this.close = () => { + dispose(); + }; + + this.checkTimer = setInterval(() => { + if (!document.body.contains(this.$el)) this.closePreview(); + }, 1000); + }, + closePreview() { + if (this.close) { + clearInterval(this.checkTimer); + this.close(); + this.close = null; + } + }, + onMouseover() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.showTimer = setTimeout(this.showPreview, 500); + }, + onMouseleave() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.hideTimer = setTimeout(this.closePreview, 500); + } + } +}); +</script> + +<style lang="scss" scoped> +.ieqqeuvs { + word-break: break-all; + + > .icon { + padding-left: 2px; + font-size: .9em; + } + + > .self { + font-weight: bold; + } + + > .schema { + opacity: 0.5; + } + + > .hostname { + font-weight: bold; + } + + > .pathname { + opacity: 0.8; + } + + > .query { + opacity: 0.5; + } + + > .hash { + font-style: italic; + } +} +</style> diff --git a/packages/client/src/components/global/user-name.vue b/packages/client/src/components/global/user-name.vue new file mode 100644 index 0000000000..bc93a8ea30 --- /dev/null +++ b/packages/client/src/components/global/user-name.vue @@ -0,0 +1,20 @@ +<template> +<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + nowrap: { + type: Boolean, + default: true + }, + } +}); +</script> diff --git a/packages/client/src/components/google.vue b/packages/client/src/components/google.vue new file mode 100644 index 0000000000..c48feffbf1 --- /dev/null +++ b/packages/client/src/components/google.vue @@ -0,0 +1,64 @@ +<template> +<div class="mk-google"> + <input type="search" v-model="query" :placeholder="q"> + <button @click="search"><i class="fas fa-search"></i> {{ $ts.search }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + q: { + type: String, + required: true, + } + }, + data() { + return { + query: null, + }; + }, + mounted() { + this.query = this.q; + }, + methods: { + search() { + window.open(`https://www.google.com/search?q=${this.query}`, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-google { + display: flex; + margin: 8px 0; + + > input { + flex-shrink: 1; + padding: 10px; + width: 100%; + height: 40px; + font-size: 16px; + border: solid 1px var(--divider); + border-radius: 4px 0 0 4px; + -webkit-appearance: textfield; + } + + > button { + flex-shrink: 0; + margin: 0; + padding: 0 16px; + border: solid 1px var(--divider); + border-left: none; + border-radius: 0 4px 4px 0; + + &:active { + box-shadow: 0 2px 4px rgba(#000, 0.15) inset; + } + } +} +</style> diff --git a/packages/client/src/components/image-viewer.vue b/packages/client/src/components/image-viewer.vue new file mode 100644 index 0000000000..fc28c30b56 --- /dev/null +++ b/packages/client/src/components/image-viewer.vue @@ -0,0 +1,85 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="xubzgfga"> + <header>{{ image.name }}</header> + <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> + <footer> + <span>{{ image.type }}</span> + <span>{{ bytes(image.size) }}</span> + <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> + </footer> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkModal from '@/components/ui/modal.vue'; + +export default defineComponent({ + components: { + MkModal, + }, + + props: { + image: { + type: Object, + required: true + }, + }, + + emits: ['closed'], + + methods: { + bytes, + number, + } +}); +</script> + +<style lang="scss" scoped> +.xubzgfga { + display: flex; + flex-direction: column; + height: 100%; + + > header, + > footer { + align-self: center; + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + flex: 1; + min-height: 0; + object-fit: contain; + width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } +} +</style> diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/img-with-blurhash.vue new file mode 100644 index 0000000000..7e80b00208 --- /dev/null +++ b/packages/client/src/components/img-with-blurhash.vue @@ -0,0 +1,100 @@ +<template> +<div class="xubzgfgb" :class="{ cover }" :title="title"> + <canvas ref="canvas" :width="size" :height="size" :title="title" v-if="!loaded"/> + <img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { decode } from 'blurhash'; + +export default defineComponent({ + props: { + src: { + type: String, + required: false, + default: null + }, + hash: { + type: String, + required: true + }, + alt: { + type: String, + required: false, + default: '', + }, + title: { + type: String, + required: false, + default: null, + }, + size: { + type: Number, + required: false, + default: 64 + }, + cover: { + type: Boolean, + required: false, + default: true, + } + }, + + data() { + return { + loaded: false, + }; + }, + + mounted() { + this.draw(); + }, + + methods: { + draw() { + if (this.hash == null) return; + const pixels = decode(this.hash, this.size, this.size); + const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d'); + const imageData = ctx!.createImageData(this.size, this.size); + imageData.data.set(pixels); + ctx!.putImageData(imageData, 0, 0); + }, + + onLoad() { + this.loaded = true; + } + } +}); +</script> + +<style lang="scss" scoped> +.xubzgfgb { + position: relative; + width: 100%; + height: 100%; + + > canvas, + > img { + display: block; + width: 100%; + height: 100%; + } + + > canvas { + position: absolute; + object-fit: cover; + } + + > img { + object-fit: contain; + } + + &.cover { + > img { + object-fit: cover; + } + } +} +</style> diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts new file mode 100644 index 0000000000..2340b228f8 --- /dev/null +++ b/packages/client/src/components/index.ts @@ -0,0 +1,37 @@ +import { App } from 'vue'; + +import mfm from './global/misskey-flavored-markdown.vue'; +import a from './global/a.vue'; +import acct from './global/acct.vue'; +import avatar from './global/avatar.vue'; +import emoji from './global/emoji.vue'; +import userName from './global/user-name.vue'; +import ellipsis from './global/ellipsis.vue'; +import time from './global/time.vue'; +import url from './global/url.vue'; +import i18n from './global/i18n'; +import loading from './global/loading.vue'; +import error from './global/error.vue'; +import ad from './global/ad.vue'; +import header from './global/header.vue'; +import spacer from './global/spacer.vue'; +import stickyContainer from './global/sticky-container.vue'; + +export default function(app: App) { + app.component('I18n', i18n); + app.component('Mfm', mfm); + app.component('MkA', a); + app.component('MkAcct', acct); + app.component('MkAvatar', avatar); + app.component('MkEmoji', emoji); + app.component('MkUserName', userName); + app.component('MkEllipsis', ellipsis); + app.component('MkTime', time); + app.component('MkUrl', url); + app.component('MkLoading', loading); + app.component('MkError', error); + app.component('MkAd', ad); + app.component('MkHeader', header); + app.component('MkSpacer', spacer); + app.component('MkStickyContainer', stickyContainer); +} diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue new file mode 100644 index 0000000000..bc62998a4a --- /dev/null +++ b/packages/client/src/components/instance-stats.vue @@ -0,0 +1,80 @@ +<template> +<div class="zbcjwnqg" style="margin-top: -8px;"> + <div class="selects" style="display: flex;"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="$ts.federation"> + <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option> + <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option> + </optgroup> + <optgroup :label="$ts.users"> + <option value="users">{{ $ts._charts.usersIncDec }}</option> + <option value="users-total">{{ $ts._charts.usersTotal }}</option> + <option value="active-users">{{ $ts._charts.activeUsers }}</option> + </optgroup> + <optgroup :label="$ts.notes"> + <option value="notes">{{ $ts._charts.notesIncDec }}</option> + <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> + <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> + <option value="notes-total">{{ $ts._charts.notesTotal }}</option> + </optgroup> + <optgroup :label="$ts.drive"> + <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> + <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option> + <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> + <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref, watch } from 'vue'; +import MkSelect from '@/components/form/select.vue'; +import MkChart from '@/components/chart.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + MkSelect, + MkChart, + }, + + props: { + chartLimit: { + type: Number, + required: false, + default: 90 + }, + detailed: { + type: Boolean, + required: false, + default: false + }, + }, + + setup() { + const chartSpan = ref<'hour' | 'day'>('hour'); + const chartSrc = ref('notes'); + + return { + chartSrc, + chartSpan, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.zbcjwnqg { + > .selects { + padding: 8px 16px 0 16px; + } +} +</style> diff --git a/packages/client/src/components/instance-ticker.vue b/packages/client/src/components/instance-ticker.vue new file mode 100644 index 0000000000..1ce5a1c2c1 --- /dev/null +++ b/packages/client/src/components/instance-ticker.vue @@ -0,0 +1,62 @@ +<template> +<div class="hpaizdrt" :style="bg"> + <img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/> + <span class="name">{{ info.name }}</span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { instanceName } from '@/config'; + +export default defineComponent({ + props: { + instance: { + type: Object, + required: false + }, + }, + + data() { + return { + info: this.instance || { + faviconUrl: '/favicon.ico', + name: instanceName, + themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content + } + } + }, + + computed: { + bg(): any { + const themeColor = this.info.themeColor || '#777777'; + return { + background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})` + }; + } + } +}); +</script> + +<style lang="scss" scoped> +.hpaizdrt { + $height: 1.1rem; + + height: $height; + border-radius: 4px 0 0 4px; + overflow: hidden; + color: #fff; + + > .icon { + height: 100%; + } + + > .name { + margin-left: 4px; + line-height: $height; + font-size: 0.9em; + vertical-align: top; + font-weight: bold; + } +} +</style> diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue new file mode 100644 index 0000000000..09f5f89f90 --- /dev/null +++ b/packages/client/src/components/launch-pad.vue @@ -0,0 +1,152 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="szkkfdyq _popup"> + <div class="main"> + <template v-for="item in items"> + <button v-if="item.action" class="_button" @click="$event => { item.action($event); close(); }" v-click-anime> + <i class="icon" :class="item.icon"></i> + <div class="text">{{ item.text }}</div> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + <MkA v-else :to="item.to" @click.passive="close()" v-click-anime> + <i class="icon" :class="item.icon"></i> + <div class="text">{{ item.text }}</div> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </MkA> + </template> + </div> + <div class="sub"> + <a href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()" v-click-anime> + <i class="fas fa-question-circle icon"></i> + <div class="text">{{ $ts.help }}</div> + </a> + <MkA to="/about" @click.passive="close()" v-click-anime> + <i class="fas fa-info-circle icon"></i> + <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div> + </MkA> + <MkA to="/about-misskey" @click.passive="close()" v-click-anime> + <img src="/static-assets/favicon.png" class="icon"/> + <div class="text">{{ $ts.aboutMisskey }}</div> + </MkA> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import { menuDef } from '@/menu'; +import { instanceName } from '@/config'; + +export default defineComponent({ + components: { + MkModal, + }, + + emits: ['closed'], + + data() { + return { + menuDef: menuDef, + items: [], + instanceName, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + }, + + created() { + this.items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ + type: def.to ? 'link' : 'button', + text: this.$ts[def.title], + icon: def.icon, + to: def.to, + action: def.action, + indicate: def.indicated, + })); + }, + + methods: { + close() { + this.$refs.modal.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.szkkfdyq { + width: 100%; + max-height: 100%; + max-width: 800px; + padding: 32px; + box-sizing: border-box; + overflow: auto; + text-align: center; + border-radius: 16px; + + @media (max-width: 500px) { + padding: 16px; + } + + > .main, > .sub { + > * { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + vertical-align: bottom; + width: 128px; + height: 128px; + border-radius: var(--radius); + + @media (max-width: 500px) { + width: 100px; + height: 100px; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + text-decoration: none; + } + + > .icon { + font-size: 26px; + height: 32px; + } + + > .text { + margin-top: 8px; + font-size: 0.9em; + line-height: 1.5em; + } + + > .indicator { + position: absolute; + top: 32px; + left: 32px; + color: var(--indicator); + font-size: 8px; + animation: blink 1s infinite; + + @media (max-width: 500px) { + top: 16px; + left: 16px; + } + } + } + } + + > .sub { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/client/src/components/link.vue b/packages/client/src/components/link.vue new file mode 100644 index 0000000000..a8e096e0a0 --- /dev/null +++ b/packages/client/src/components/link.vue @@ -0,0 +1,92 @@ +<template> +<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" + @mouseover="onMouseover" + @mouseleave="onMouseleave" + :title="url" +> + <slot></slot> + <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> +</component> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { url as local } from '@/config'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + url: { + type: String, + required: true, + }, + rel: { + type: String, + required: false, + } + }, + data() { + const self = this.url.startsWith(local); + return { + local, + self: self, + attr: self ? 'to' : 'href', + target: self ? null : '_blank', + showTimer: null, + hideTimer: null, + checkTimer: null, + close: null, + }; + }, + methods: { + async showPreview() { + if (!document.body.contains(this.$el)) return; + if (this.close) return; + + const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), { + url: this.url, + source: this.$el + }); + + this.close = () => { + dispose(); + }; + + this.checkTimer = setInterval(() => { + if (!document.body.contains(this.$el)) this.closePreview(); + }, 1000); + }, + closePreview() { + if (this.close) { + clearInterval(this.checkTimer); + this.close(); + this.close = null; + } + }, + onMouseover() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.showTimer = setTimeout(this.showPreview, 500); + }, + onMouseleave() { + if (isDeviceTouch) return; + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.hideTimer = setTimeout(this.closePreview, 500); + } + } +}); +</script> + +<style lang="scss" scoped> +.xlcxczvw { + word-break: break-all; + + > .icon { + padding-left: 2px; + font-size: .9em; + } +} +</style> diff --git a/packages/client/src/components/media-banner.vue b/packages/client/src/components/media-banner.vue new file mode 100644 index 0000000000..2cf8c772e5 --- /dev/null +++ b/packages/client/src/components/media-banner.vue @@ -0,0 +1,107 @@ +<template> +<div class="mk-media-banner"> + <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> + <span class="icon"><i class="fas fa-exclamation-triangle"></i></span> + <b>{{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> + <div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'"> + <audio class="audio" + :src="media.url" + :title="media.name" + controls + ref="audio" + @volumechange="volumechange" + preload="metadata" /> + </div> + <a class="download" v-else + :href="media.url" + :title="media.name" + :download="media.name" + > + <span class="icon"><i class="fas fa-download"></i></span> + <b>{{ media.name }}</b> + </a> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; + +export default defineComponent({ + props: { + media: { + type: Object, + required: true + } + }, + data() { + return { + hide: true, + }; + }, + mounted() { + const audioTag = this.$refs.audio as HTMLAudioElement; + if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); + }, + methods: { + volumechange() { + const audioTag = this.$refs.audio as HTMLAudioElement; + ColdDeviceStorage.set('mediaVolume', audioTag.volume); + }, + }, +}) +</script> + +<style lang="scss" scoped> +.mk-media-banner { + width: 100%; + border-radius: 4px; + margin-top: 4px; + overflow: hidden; + + > .download, + > .sensitive { + display: flex; + align-items: center; + font-size: 12px; + padding: 8px 12px; + white-space: nowrap; + + > * { + display: block; + } + + > b { + overflow: hidden; + text-overflow: ellipsis; + } + + > *:not(:last-child) { + margin-right: .2em; + } + + > .icon { + font-size: 1.6em; + } + } + + > .download { + background: var(--noteAttachedFile); + } + + > .sensitive { + background: #111; + color: #fff; + } + + > .audio { + .audio { + display: block; + width: 100%; + } + } +} +</style> diff --git a/packages/client/src/components/media-caption.vue b/packages/client/src/components/media-caption.vue new file mode 100644 index 0000000000..08a3ca2b4c --- /dev/null +++ b/packages/client/src/components/media-caption.vue @@ -0,0 +1,259 @@ +<template> + <MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> + <div class="container"> + <div class="fullwidth top-caption"> + <div class="mk-dialog"> + <header> + <Mfm v-if="title" class="title" :text="title"/> + <span class="text-count" :class="{ over: remainingLength < 0 }">{{ remainingLength }}</span> + </header> + <textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea> + <div class="buttons" v-if="(showOkButton || showCancelButton)"> + <MkButton inline @click="ok" primary :disabled="remainingLength < 0">{{ $ts.ok }}</MkButton> + <MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton> + </div> + </div> + </div> + <div class="hdrwpsaf fullwidth"> + <header>{{ image.name }}</header> + <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> + <footer> + <span>{{ image.type }}</span> + <span>{{ bytes(image.size) }}</span> + <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> + </footer> + </div> + </div> + </MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { length } from 'stringz'; +import MkModal from '@/components/ui/modal.vue'; +import MkButton from '@/components/ui/button.vue'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkModal, + MkButton, + }, + + props: { + image: { + type: Object, + required: true, + }, + title: { + type: String, + required: false + }, + input: { + required: true + }, + showOkButton: { + type: Boolean, + default: true + }, + showCancelButton: { + type: Boolean, + default: true + }, + cancelableByBgClick: { + type: Boolean, + default: true + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + inputValue: this.input.default ? this.input.default : null + }; + }, + + computed: { + remainingLength(): number { + if (typeof this.inputValue != "string") return 512; + return 512 - length(this.inputValue); + } + }, + + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + + beforeUnmount() { + document.removeEventListener('keydown', this.onKeydown); + }, + + methods: { + bytes, + number, + + done(canceled, result?) { + this.$emit('done', { canceled, result }); + this.$refs.modal.close(); + }, + + async ok() { + if (!this.showOkButton) return; + + const result = this.inputValue; + this.done(false, result); + }, + + cancel() { + this.done(true); + }, + + onBgClick() { + if (this.cancelableByBgClick) { + this.cancel(); + } + }, + + onKeydown(e) { + if (e.which === 27) { // ESC + this.cancel(); + } + }, + + onInputKeydown(e) { + if (e.which === 13) { // Enter + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } + } +}); +</script> + +<style lang="scss" scoped> +.container { + display: flex; + width: 100%; + height: 100%; + flex-direction: row; +} +@media (max-width: 850px) { + .container { + flex-direction: column; + } + .top-caption { + padding-bottom: 8px; + } +} +.fullwidth { + width: 100%; + margin: auto; +} +.mk-dialog { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + margin: auto; + + > header { + margin: 0 0 8px 0; + position: relative; + + > .title { + font-weight: bold; + font-size: 20px; + } + + > .text-count { + opacity: 0.7; + position: absolute; + right: 0; + } + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } + + > textarea { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + max-width: 100%; + min-width: 100%; + min-height: 90px; + + &:focus-visible { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } +} +.hdrwpsaf { + display: flex; + flex-direction: column; + height: 100%; + + > header, + > footer { + align-self: center; + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + flex: 1; + min-height: 0; + object-fit: contain; + width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } +} +</style> diff --git a/packages/client/src/components/media-image.vue b/packages/client/src/components/media-image.vue new file mode 100644 index 0000000000..8843b63207 --- /dev/null +++ b/packages/client/src/components/media-image.vue @@ -0,0 +1,155 @@ +<template> +<div class="qjewsnkg" v-if="hide" @click="hide = false"> + <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> + <div class="text"> + <div> + <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> + </div> +</div> +<div class="gqnyydlz" :style="{ background: color }" v-else> + <a + :href="image.url" + :title="image.name" + > + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> + <div class="gif" v-if="image.type === 'image/gif'">GIF</div> + </a> + <i class="fas fa-eye-slash" @click="hide = true"></i> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; +import ImageViewer from './image-viewer.vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + ImgWithBlurhash + }, + props: { + image: { + type: Object, + required: true + }, + raw: { + default: false + } + }, + data() { + return { + hide: true, + color: null, + }; + }, + computed: { + url(): any { + let url = this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(this.image.thumbnailUrl) + : this.image.thumbnailUrl; + + if (this.raw || this.$store.state.loadRawImages) { + url = this.image.url; + } + + return url; + } + }, + created() { + // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする + this.$watch('image', () => { + this.hide = (this.$store.state.nsfw === 'force') ? true : this.image.isSensitive && (this.$store.state.nsfw !== 'ignore'); + if (this.image.blurhash) { + this.color = extractAvgColorFromBlurhash(this.image.blurhash); + } + }, { + deep: true, + immediate: true, + }); + }, +}); +</script> + +<style lang="scss" scoped> +.qjewsnkg { + position: relative; + + > .bg { + filter: brightness(0.5); + } + + > .text { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + + > div { + display: table-cell; + text-align: center; + font-size: 0.8em; + color: #fff; + + > * { + display: block; + } + } + } +} + +.gqnyydlz { + position: relative; + border: solid 0.5px var(--divider); + + > i { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 14px; + opacity: .5; + padding: 3px 6px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; + } + + > a { + display: block; + cursor: zoom-in; + overflow: hidden; + width: 100%; + height: 100%; + background-position: center; + background-size: contain; + background-repeat: no-repeat; + + > .gif { + background-color: var(--fg); + border-radius: 6px; + color: var(--accentLighten); + display: inline-block; + font-size: 14px; + font-weight: bold; + left: 12px; + opacity: .5; + padding: 0 6px; + text-align: center; + top: 12px; + pointer-events: none; + } + } +} +</style> diff --git a/packages/client/src/components/media-list.vue b/packages/client/src/components/media-list.vue new file mode 100644 index 0000000000..51eaa86f35 --- /dev/null +++ b/packages/client/src/components/media-list.vue @@ -0,0 +1,167 @@ +<template> +<div class="hoawjimk"> + <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :media="media" :key="media.id"/> + <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> + <div :data-count="mediaList.filter(media => previewable(media)).length" ref="gallery"> + <template v-for="media in mediaList"> + <XVideo :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> + <XImage class="image" :data-id="media.id" :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, ref } from 'vue'; +import * as misskey from 'misskey-js'; +import PhotoSwipeLightbox from 'photoswipe/dist/photoswipe-lightbox.esm.js'; +import PhotoSwipe from 'photoswipe/dist/photoswipe.esm.js'; +import 'photoswipe/dist/photoswipe.css'; +import XBanner from './media-banner.vue'; +import XImage from './media-image.vue'; +import XVideo from './media-video.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + XBanner, + XImage, + XVideo, + }, + props: { + mediaList: { + type: Array as PropType<misskey.entities.DriveFile[]>, + required: true, + }, + raw: { + default: false + }, + }, + setup(props) { + const gallery = ref(null); + + onMounted(() => { + const lightbox = new PhotoSwipeLightbox({ + dataSource: props.mediaList.filter(media => media.type.startsWith('image')).map(media => ({ + src: media.url, + w: media.properties.width, + h: media.properties.height, + alt: media.name, + })), + gallery: gallery.value, + children: '.image', + thumbSelector: '.image', + pswpModule: PhotoSwipe + }); + + lightbox.on('itemData', (e) => { + const { itemData } = e; + + // element is children + const { element } = itemData; + + const id = element.dataset.id; + const file = props.mediaList.find(media => media.id === id); + + itemData.src = file.url; + itemData.w = Number(file.properties.width); + itemData.h = Number(file.properties.height); + itemData.msrc = file.thumbnailUrl; + itemData.thumbCropped = true; + }); + + lightbox.init(); + }); + + const previewable = (file: misskey.entities.DriveFile): boolean => { + return file.type.startsWith('video') || file.type.startsWith('image'); + }; + + return { + previewable, + gallery, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.hoawjimk { + > .gird-container { + position: relative; + width: 100%; + margin-top: 4px; + + &:before { + content: ''; + display: block; + padding-top: 56.25% // 16:9; + } + + > div { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + display: grid; + grid-gap: 4px; + + > * { + overflow: hidden; + border-radius: 6px; + } + + &[data-count="1"] { + grid-template-rows: 1fr; + } + + &[data-count="2"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr; + } + + &[data-count="3"] { + grid-template-columns: 1fr 0.5fr; + grid-template-rows: 1fr 1fr; + + > *:nth-child(1) { + grid-row: 1 / 3; + } + + > *:nth-child(3) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + + &[data-count="4"] { + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + } + + > *:nth-child(1) { + grid-column: 1 / 2; + grid-row: 1 / 2; + } + + > *:nth-child(2) { + grid-column: 2 / 3; + grid-row: 1 / 2; + } + + > *:nth-child(3) { + grid-column: 1 / 2; + grid-row: 2 / 3; + } + + > *:nth-child(4) { + grid-column: 2 / 3; + grid-row: 2 / 3; + } + } + } +} +</style> diff --git a/packages/client/src/components/media-video.vue b/packages/client/src/components/media-video.vue new file mode 100644 index 0000000000..aa885bd564 --- /dev/null +++ b/packages/client/src/components/media-video.vue @@ -0,0 +1,97 @@ +<template> +<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="hide" @click="hide = false"> + <div> + <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> + <span>{{ $ts.clickToShow }}</span> + </div> +</div> +<div class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else> + <video + :poster="video.thumbnailUrl" + :title="video.name" + preload="none" + controls + @contextmenu.stop + > + <source + :src="video.url" + :type="video.type" + > + </video> + <i class="fas fa-eye-slash" @click="hide = true"></i> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + video: { + type: Object, + required: true + } + }, + data() { + return { + hide: true, + }; + }, + created() { + this.hide = (this.$store.state.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.nsfw !== 'ignore'); + }, +}); +</script> + +<style lang="scss" scoped> +.kkjnbbplepmiyuadieoenjgutgcmtsvu { + position: relative; + + > i { + display: block; + position: absolute; + border-radius: 6px; + background-color: var(--fg); + color: var(--accentLighten); + font-size: 14px; + opacity: .5; + padding: 3px 6px; + text-align: center; + cursor: pointer; + top: 12px; + right: 12px; + } + + > video { + display: flex; + justify-content: center; + align-items: center; + + font-size: 3.5em; + overflow: hidden; + background-position: center; + background-size: cover; + width: 100%; + height: 100%; + } +} + +.icozogqfvdetwohsdglrbswgrejoxbdj { + display: flex; + justify-content: center; + align-items: center; + background: #111; + color: #fff; + + > div { + display: table-cell; + text-align: center; + font-size: 12px; + + > b { + display: block; + } + } +} +</style> diff --git a/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue new file mode 100644 index 0000000000..a5be3fab22 --- /dev/null +++ b/packages/client/src/components/mention.vue @@ -0,0 +1,84 @@ +<template> +<MkA v-if="url.startsWith('/')" class="ldlomzub" :class="{ isMe }" :to="url" v-user-preview="canonical" :style="{ background: bg }"> + <img class="icon" :src="`/avatar/@${username}@${host}`" alt=""> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host" v-if="(host != localHost) || $store.state.showFullAcct">@{{ toUnicode(host) }}</span> + </span> +</MkA> +<a v-else class="ldlomzub" :href="url" target="_blank" rel="noopener" :style="{ background: bg }"> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host">@{{ toUnicode(host) }}</span> + </span> +</a> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import { toUnicode } from 'punycode'; +import { host as localHost } from '@/config'; +import { $i } from '@/account'; + +export default defineComponent({ + props: { + username: { + type: String, + required: true + }, + host: { + type: String, + required: true + } + }, + + setup(props) { + const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; + + const url = `/${canonical}`; + + const isMe = $i && ( + `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() + ); + + const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); + bg.setAlpha(0.1); + + return { + localHost, + isMe, + url, + canonical, + toUnicode, + bg: bg.toRgbString(), + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ldlomzub { + display: inline-block; + padding: 4px 8px 4px 4px; + border-radius: 999px; + color: var(--mention); + + &.isMe { + color: var(--mentionMe); + } + + > .icon { + width: 1.5em; + margin: 0 0.2em 0 0; + vertical-align: bottom; + border-radius: 100%; + } + + > .main { + > .host { + opacity: 0.5; + } + } +} +</style> diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts new file mode 100644 index 0000000000..d41cf6fc2b --- /dev/null +++ b/packages/client/src/components/mfm.ts @@ -0,0 +1,321 @@ +import { VNode, defineComponent, h } from 'vue'; +import * as mfm from 'mfm-js'; +import MkUrl from '@/components/global/url.vue'; +import MkLink from '@/components/link.vue'; +import MkMention from '@/components/mention.vue'; +import MkEmoji from '@/components/global/emoji.vue'; +import { concat } from '@/scripts/array'; +import MkFormula from '@/components/formula.vue'; +import MkCode from '@/components/code.vue'; +import MkGoogle from '@/components/google.vue'; +import MkSparkle from '@/components/sparkle.vue'; +import MkA from '@/components/global/a.vue'; +import { host } from '@/config'; +import { MFM_TAGS } from '@/scripts/mfm-tags'; + +export default defineComponent({ + props: { + text: { + type: String, + required: true + }, + plain: { + type: Boolean, + default: false + }, + nowrap: { + type: Boolean, + default: false + }, + author: { + type: Object, + default: null + }, + i: { + type: Object, + default: null + }, + customEmojis: { + required: false, + }, + isNote: { + type: Boolean, + default: true + }, + }, + + render() { + if (this.text == null || this.text == '') return; + + const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text, { fnNameList: MFM_TAGS }); + + const validTime = (t: string | null | undefined) => { + if (t == null) return null; + return t.match(/^[0-9.]+s$/) ? t : null; + }; + + const genEl = (ast: mfm.MfmNode[]) => concat(ast.map((token): VNode[] => { + switch (token.type) { + case 'text': { + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + + if (!this.plain) { + const res = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children))]; + } + + case 'strike': { + return [h('del', genEl(token.children))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;' + }, genEl(token.children)); + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style; + switch (token.props.name) { + case 'tada': { + style = `font-size: 150%;` + (this.$store.state.animatedMfm ? 'animation: tada 1s linear infinite both;' : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) || '1s'; + style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) || '1.5s'; + style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + break; + } + case 'jump': { + style = this.$store.state.animatedMfm ? 'animation: mfm-jump 0.75s linear infinite;' : ''; + break; + } + case 'bounce': { + style = this.$store.state.animatedMfm ? 'animation: mfm-bounce 0.75s linear infinite; transform-origin: center bottom;' : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + style = `font-size: 200%;`; + break; + } + case 'x3': { + style = `font-size: 400%;`; + break; + } + case 'x4': { + style = `font-size: 600%;`; + break; + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children)); + } + case 'rainbow': { + style = this.$store.state.animatedMfm ? 'animation: mfm-rainbow 1s linear infinite;' : ''; + break; + } + case 'sparkle': { + if (!this.$store.state.animatedMfm) { + return genEl(token.children); + } + let count = token.props.args.count ? parseInt(token.props.args.count) : 10; + if (count > 100) { + count = 100; + } + const speed = token.props.args.speed ? parseFloat(token.props.args.speed) : 1; + return h(MkSparkle, { + count, speed, + }, genEl(token.children)); + } + } + if (style == null) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); + } else { + return h('span', { + style: 'display: inline-block;' + style, + }, genEl(token.children)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;' + }, genEl(token.children))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;' + }, genEl(token.children))]; + } + + case 'url': { + return [h(MkUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(MkLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children))]; + } + + case 'mention': { + return [h(MkMention, { + key: Math.random(), + host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, + username: token.props.username + })]; + } + + case 'hashtag': { + return [h(MkA, { + key: Math.random(), + to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);' + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + lang: token.props.lang, + })]; + } + + case 'inlineCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + inline: true + })]; + } + + case 'quote': { + if (!this.nowrap) { + return [h('div', { + class: 'quote' + }, genEl(token.children))]; + } else { + return [h('span', { + class: 'quote' + }, genEl(token.children))]; + } + } + + case 'emojiCode': { + return [h(MkEmoji, { + key: Math.random(), + emoji: `:${token.props.name}:`, + customEmojis: this.customEmojis, + normal: this.plain + })]; + } + + case 'unicodeEmoji': { + return [h(MkEmoji, { + key: Math.random(), + emoji: token.props.emoji, + customEmojis: this.customEmojis, + normal: this.plain + })]; + } + + case 'mathInline': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: false + })]; + } + + case 'mathBlock': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: true + })]; + } + + case 'search': { + return [h(MkGoogle, { + key: Math.random(), + q: token.props.query + })]; + } + + default: { + console.error('unrecognized ast type:', token.type); + + return []; + } + } + })); + + // Parse ast to DOM + return h('span', genEl(ast)); + } +}); diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue new file mode 100644 index 0000000000..2eb9ae8cbe --- /dev/null +++ b/packages/client/src/components/mini-chart.vue @@ -0,0 +1,90 @@ +<template> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible"> + <defs> + <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> + </linearGradient> + <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="polygonPoints" + fill="#fff" + fill-opacity="0.5"/> + <polyline + :points="polylinePoints" + fill="none" + stroke="#fff" + stroke-width="2"/> + <circle + :cx="headX" + :cy="headY" + r="3" + fill="#fff"/> + </mask> + </defs> + <rect + x="-10" y="-10" + :width="viewBoxX + 20" :height="viewBoxY + 20" + :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/> +</svg> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + src: { + type: Array, + required: true + } + }, + data() { + return { + viewBoxX: 50, + viewBoxY: 30, + gradientId: uuid(), + maskId: uuid(), + polylinePoints: '', + polygonPoints: '', + headX: null, + headY: null, + clock: null + }; + }, + watch: { + src() { + this.draw(); + } + }, + created() { + this.draw(); + + // Vueが何故かWatchを発動させない場合があるので + this.clock = setInterval(this.draw, 1000); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + draw() { + const stats = this.src.slice().reverse(); + const peak = Math.max.apply(null, stats) || 1; + + const polylinePoints = stats.map((n, i) => [ + i * (this.viewBoxX / (stats.length - 1)), + (1 - (n / peak)) * this.viewBoxY + ]); + + this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + + this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + + this.headX = polylinePoints[polylinePoints.length - 1][0]; + this.headY = polylinePoints[polylinePoints.length - 1][1]; + } + } +}); +</script> diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue new file mode 100644 index 0000000000..2086736683 --- /dev/null +++ b/packages/client/src/components/modal-page-window.vue @@ -0,0 +1,223 @@ +<template> +<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> + <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> + <div class="header" @contextmenu="onContextmenu"> + <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> + <span v-else style="display: inline-block; width: 20px"></span> + <span v-if="pageInfo" class="title"> + <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i> + <span>{{ pageInfo.title }}</span> + </span> + <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button> + </div> + <div class="body"> + <MkStickyContainer> + <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> + <keep-alive> + <component :is="component" v-bind="props" :ref="changePage"/> + </keep-alive> + </MkStickyContainer> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import { popout } from '@/scripts/popout'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; +import * as symbols from '@/symbols'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkModal, + }, + + inject: { + sideViewHook: { + default: null + } + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + }, + shouldHeaderThin: true, + }; + }, + + props: { + initialPath: { + type: String, + required: true, + }, + initialComponent: { + type: Object, + required: true, + }, + initialProps: { + type: Object, + required: false, + default: () => {}, + }, + }, + + emits: ['closed'], + + data() { + return { + width: 860, + height: 660, + pageInfo: null, + path: this.initialPath, + component: this.initialComponent, + props: this.initialProps, + history: [], + }; + }, + + computed: { + url(): string { + return url + this.path; + }, + + contextmenu() { + return [{ + type: 'label', + text: this.path, + }, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: this.expand + }, this.sideViewHook ? { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.sideViewHook(this.path); + this.$refs.window.close(); + } + } : undefined, { + icon: 'fas fa-external-link-alt', + text: this.$ts.popout, + action: this.popout + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.$refs.window.close(); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }]; + }, + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + navigate(path, record = true) { + if (record) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + expand() { + this.$router.push(this.path); + this.$refs.window.close(); + }, + + popout() { + popout(this.path, this.$el); + this.$refs.window.close(); + }, + + onContextmenu(e) { + os.contextMenu(this.contextmenu, e); + } + }, +}); +</script> + +<style lang="scss" scoped> +.hrmcaedk { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + + --root-margin: 24px; + + @media (max-width: 500px) { + --root-margin: 16px; + } + + > .header { + $height: 52px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + height: $height; + line-height: $height; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + box-shadow: 0px 1px var(--divider); + + > button { + height: $height; + width: $height; + + &:hover { + color: var(--fgHighlighted); + } + } + + @media (max-width: 500px) { + height: $height-narrow; + line-height: $height-narrow; + padding-left: 16px; + + > button { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + + > .icon { + margin-right: 0.5em; + } + } + } + + > .body { + overflow: auto; + background: var(--bg); + } +} +</style> diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue new file mode 100644 index 0000000000..8b6905a0e4 --- /dev/null +++ b/packages/client/src/components/note-detailed.vue @@ -0,0 +1,1229 @@ +<template> +<div + class="lxwezrsl _block" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" + v-size="{ max: [500, 450, 350, 300] }" +> + <XSub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="fas fa-retweet"></i> + <I18n :src="$ts.renotedBy" tag="span"> + <template #user> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> + <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <header class="header"> + <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/> + <div class="body"> + <div class="top"> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.user.id"> + <MkUserName :user="appearNote.user"/> + </MkA> + <span class="is-bot" v-if="appearNote.user.isBot">bot</span> + <span class="admin" v-if="appearNote.user.isAdmin"><i class="fas fa-bookmark"></i></span> + <span class="moderator" v-if="!appearNote.user.isAdmin && appearNote.user.isModerator"><i class="far fa-bookmark"></i></span> + <span class="visibility" v-if="appearNote.visibility !== 'public'"> + <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="appearNote.localOnly"><i class="fas fa-biohazard"></i></span> + </div> + <div class="username"><MkAcct :user="appearNote.user"/></div> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + </div> + </header> + <div class="main"> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model="showContent" :note="appearNote"/> + </p> + <div class="content" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + <div class="translation" v-if="translating || translation"> + <MkLoading v-if="translating" mini/> + <div class="translated" v-else> + <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b> + {{ translation.text }} + </div> + </div> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="true" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer class="footer"> + <div class="info"> + <span class="mobile" v-if="appearNote.viaMobile"><i class="fas fa-mobile-alt"></i></span> + <MkTime class="created-at" :time="appearNote.createdAt" mode="detail"/> + </div> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> + <template v-else><i class="fas fa-reply"></i></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> + <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <i class="fas fa-ban"></i> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <i class="fas fa-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <i class="fas fa-minus"></i> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <i class="fas fa-ellipsis-h"></i> + </button> + </footer> + </div> + </article> + <XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> +</div> +<div v-else class="_panel muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as mfm from 'mfm-js'; +import { sum } from '@/scripts/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNoteSimple from './note-simple.vue'; +import XReactionsViewer from './reactions-viewer.vue'; +import XMediaList from './media-list.vue'; +import XCwButton from './cw-button.vue'; +import XPoll from './poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +// TODO: note.vueとほぼ同じなので共通化したい +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNoteSimple, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + conversation: [], + replies: [], + showContent: false, + isDeleted: false, + muted: false, + translation: null, + translating: false, + }; + }, + + computed: { + rs() { + return this.$store.state.reactions; + }, + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$i && (this.$i.id === this.appearNote.userId); + }, + + isMyRenote(): boolean { + return this.$i && (this.$i.id === this.note.userId); + }, + + canRenote(): boolean { + return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + urls(): string[] { + if (this.appearNote.text) { + return extractUrlFromMfm(mfm.parse(this.appearNote.text)); + } else { + return null; + } + }, + + showTicker() { + if (this.$store.state.instanceTicker === 'always') return true; + if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; + return false; + } + }, + + async created() { + if (this.$i) { + this.connection = os.stream; + } + + this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); + + // plugin + if (noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); + } + this.$emit('update:note', Object.freeze(result)); + } + + os.api('notes/children', { + noteId: this.appearNote.id, + limit: 30 + }).then(replies => { + this.replies = replies; + }); + + if (this.appearNote.replyId) { + os.api('notes/conversation', { + noteId: this.appearNote.replyId + }).then(conversation => { + this.conversation = conversation.reverse(); + }); + } + }, + + mounted() { + this.capture(true); + + if (this.$i) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeUnmount() { + this.decapture(true); + + if (this.$i) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + updateAppearNote(v) { + this.$emit('update:note', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + + readPromo() { + os.api('promo/read', { + noteId: this.appearNote.id + }); + this.isDeleted = true; + }, + + capture(withHandler = false) { + if (this.$i) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$i) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + n.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Increment the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; + + if (body.userId === this.$i.id) { + n.myReaction = reaction; + } + + this.updateAppearNote(n); + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Decrement the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; + + if (body.userId === this.$i.id) { + n.myReaction = null; + } + + this.updateAppearNote(n); + break; + } + + case 'pollVoted': { + const choice = body.choice; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + const choices = [...this.appearNote.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...(body.userId === this.$i.id ? { + isVoted: true + } : {}) + }; + + n.poll = { + ...this.appearNote.poll, + choices: choices + }; + + this.updateAppearNote(n); + break; + } + + case 'deleted': { + this.isDeleted = true; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(); + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + os.popupMenu([{ + text: this.$ts.renote, + icon: 'fas fa-retweet', + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: 'fas fa-quote-right', + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }); + }, + + renoteDirectly() { + os.apiWithDialog('notes/create', { + renoteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.renoted, + }); + }, (e: Error) => { + if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { + os.dialog({ + type: 'error', + text: this.$ts.cantRenote, + }); + } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { + os.dialog({ + type: 'error', + text: this.$ts.cantReRenote, + }); + } + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + reactionPicker.show(this.$refs.reactButton, reaction => { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, () => { + this.focus(); + }); + }, + + reactDirectly(reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { + noteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.favorited, + }); + }, (e: Error) => { + if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { + os.dialog({ + type: 'error', + text: this.$ts.alreadyFavorited, + }); + } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { + os.dialog({ + type: 'error', + text: this.$ts.cantFavorite, + }); + } + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.noteDeleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + delEdit() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteAndEditConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + }); + }, + + toggleFavorite(favorite: boolean) { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.appearNote.id + }); + }, + + toggleWatch(watch: boolean) { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.appearNote.id + }); + }, + + toggleThreadMute(mute: boolean) { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: this.appearNote.id + }); + }, + + getMenu() { + let menu; + if (this.$i) { + const statePromise = os.api('notes/state', { + noteId: this.appearNote.id + }); + + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + { + icon: 'fas fa-share-alt', + text: this.$ts.share, + action: this.share + }, + this.$instance.translatorAvailable ? { + icon: 'fas fa-language', + text: this.$ts.translate, + action: this.translate + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: 'fas fa-star', + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: 'fas fa-paperclip', + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'fas fa-eye-slash', + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: 'fas fa-eye', + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: this.$ts.unmuteThread, + action: () => this.toggleThreadMute(false) + } : { + icon: 'fas fa-comment-slash', + text: this.$ts.muteThread, + action: () => this.toggleThreadMute(true) + }), + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: 'fas fa-thumbtack', + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: 'fas fa-edit', + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'fas fa-plug', + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } + }, + + menu(viaKeyboard = false) { + os.popupMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(this.focus); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.popupMenu([{ + text: this.$ts.unrenote, + icon: 'fas fa-trash-alt', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { + viaKeyboard: viaKeyboard + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + copyContent() { + copyToClipboard(this.appearNote.text); + os.success(); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.appearNote.id}`); + os.success(); + }, + + togglePin(pin: boolean) { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: this.appearNote.id + }, undefined, null, e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.dialog({ + type: 'error', + text: this.$ts.pinLimitExceeded + }); + } + }); + }, + + async clip() { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'fas fa-plus', + text: this.$ts.createNew, + action: async () => { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }))], this.$refs.menuButton, { + }).then(this.focus); + }, + + async promote() { + const { canceled, result: days } = await os.dialog({ + title: this.$ts.numberOfDays, + input: { type: 'number' } + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }); + }, + + share() { + navigator.share({ + title: this.$t('noteOf', { user: this.appearNote.user.name }), + text: this.appearNote.text, + url: `${url}/notes/${this.appearNote.id}` + }); + }, + + async translate() { + if (this.translation != null) return; + this.translating = true; + const res = await os.api('notes/translate', { + noteId: this.appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + this.translating = false; + this.translation = res; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.lxwezrsl { + position: relative; + transition: box-shadow 0.1s ease; + overflow: hidden; + contain: content; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .reply-to-more { + opacity: 0.7; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > i { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + padding: 32px; + font-size: 1.1em; + + > .header { + display: flex; + position: relative; + margin-bottom: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + width: 58px; + height: 58px; + } + + > .body { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding-left: 16px; + font-size: 0.95em; + + > .top { + > .name { + font-weight: bold; + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 0.5em; + padding: 4px 6px; + font-size: 80%; + border: solid 0.5px var(--divider); + border-radius: 4px; + } + + > .admin, + > .moderator { + margin-right: 0.5em; + color: var(--badge); + } + } + } + } + + > .main { + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + > .info { + margin: 16px 0; + opacity: 0.7; + font-size: 0.9em; + } + + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 16px; + + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .header { + > .avatar { + width: 50px; + height: 50px; + } + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} +</style> diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue new file mode 100644 index 0000000000..c61ec41dd1 --- /dev/null +++ b/packages/client/src/components/note-header.vue @@ -0,0 +1,115 @@ +<template> +<header class="kkwtjztg"> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> + <MkUserName :user="note.user"/> + </MkA> + <div class="is-bot" v-if="note.user.isBot">bot</div> + <div class="username"><MkAcct :user="note.user"/></div> + <div class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></div> + <div class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></div> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span> + <MkA class="created-at" :to="notePage(note)"> + <MkTime :time="note.createdAt"/> + </MkA> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> +</header> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import notePage from '@/filters/note'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + note: { + type: Object, + required: true + }, + }, + + data() { + return { + }; + }, + + methods: { + notePage, + userPage + } +}); +</script> + +<style lang="scss" scoped> +.kkwtjztg { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + flex-shrink: 1; + display: block; + margin: 0 .5em 0 0; + padding: 0; + overflow: hidden; + font-size: 1em; + font-weight: bold; + text-decoration: none; + text-overflow: ellipsis; + + &:hover { + text-decoration: underline; + } + } + + > .is-bot { + flex-shrink: 0; + align-self: center; + margin: 0 .5em 0 0; + padding: 1px 6px; + font-size: 80%; + border: solid 0.5px var(--divider); + border-radius: 3px; + } + + > .admin, + > .moderator { + flex-shrink: 0; + margin-right: 0.5em; + color: var(--badge); + } + + > .username { + flex-shrink: 9999999; + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + } + + > .info { + flex-shrink: 0; + margin-left: auto; + font-size: 0.9em; + + > .mobile { + margin-right: 8px; + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } +} +</style> diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue new file mode 100644 index 0000000000..a474a01341 --- /dev/null +++ b/packages/client/src/components/note-preview.vue @@ -0,0 +1,98 @@ +<template> +<div class="fefdfafb" v-size="{ min: [350, 500] }"> + <MkAvatar class="avatar" :user="$i"/> + <div class="main"> + <div class="header"> + <MkUserName :user="$i"/> + </div> + <div class="body"> + <div class="content"> + <Mfm :text="text" :author="$i" :i="$i"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + components: { + }, + + props: { + text: { + type: String, + required: true + } + }, +}); +</script> + +<style lang="scss" scoped> +.fefdfafb { + display: flex; + margin: 0; + padding: 0; + overflow: clip; + font-size: 0.95em; + + &.min-width_350px { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } + + &.min-width_500px { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue new file mode 100644 index 0000000000..2f19bd6e0b --- /dev/null +++ b/packages/client/src/components/note-simple.vue @@ -0,0 +1,113 @@ +<template> +<div class="yohlumlk" v-size="{ min: [350, 500] }"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="main"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> + <XCwButton v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.yohlumlk { + display: flex; + margin: 0; + padding: 0; + overflow: clip; + font-size: 0.95em; + + &.min-width_350px { + > .avatar { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + } + + &.min-width_500px { + > .avatar { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/note.sub.vue b/packages/client/src/components/note.sub.vue new file mode 100644 index 0000000000..45204854be --- /dev/null +++ b/packages/client/src/components/note.sub.vue @@ -0,0 +1,146 @@ +<template> +<div class="wrpstxzv" :class="{ children }" v-size="{ max: [450] }"> + <div class="main"> + <MkAvatar class="avatar" :user="note.user"/> + <div class="body"> + <XNoteHeader class="header" :note="note" :mini="true"/> + <div class="body"> + <p v-if="note.cw != null" class="cw"> + <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> + <XCwButton v-model="showContent" :note="note"/> + </p> + <div class="content" v-show="note.cw == null || showContent"> + <XSubNote-content class="text" :note="note"/> + </div> + </div> + </div> + </div> + <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNoteHeader from './note-header.vue'; +import XSubNoteContent from './sub-note-content.vue'; +import XCwButton from './cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + name: 'XSub', + + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + children: { + type: Boolean, + required: false, + default: false + }, + // TODO + truncate: { + type: Boolean, + default: true + } + }, + + data() { + return { + showContent: false, + replies: [], + }; + }, + + created() { + if (this.detail) { + os.api('notes/children', { + noteId: this.note.id, + limit: 5 + }).then(replies => { + this.replies = replies; + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.wrpstxzv { + padding: 16px 32px; + font-size: 0.9em; + + &.max-width_450px { + padding: 14px 16px; + } + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + + &.max-width_450px { + padding: 10px 0 0 8px; + } + } + + > .main { + display: flex; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 38px; + height: 38px; + border-radius: 8px; + } + + > .body { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } + } + + > .reply { + border-left: solid 0.5px var(--divider); + margin-top: 10px; + } +} +</style> diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue new file mode 100644 index 0000000000..b1ec674b67 --- /dev/null +++ b/packages/client/src/components/note.vue @@ -0,0 +1,1228 @@ +<template> +<div + class="tkcbzcuz" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote }" + v-hotkey="keymap" + v-size="{ max: [500, 450, 350, 300] }" +> + <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> + <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> + <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> + <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> + <div class="renote" v-if="isRenote"> + <MkAvatar class="avatar" :user="note.user"/> + <i class="fas fa-retweet"></i> + <I18n :src="$ts.renotedBy" tag="span"> + <template #user> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> + <MkUserName :user="note.user"/> + </MkA> + </template> + </I18n> + <div class="info"> + <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> + <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> + <MkTime :time="note.createdAt"/> + </button> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> + </div> + <article class="article" @contextmenu.stop="onContextmenu"> + <MkAvatar class="avatar" :user="appearNote.user"/> + <div class="main"> + <XNoteHeader class="header" :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> + <div class="body"> + <p v-if="appearNote.cw != null" class="cw"> + <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <XCwButton v-model="showContent" :note="appearNote"/> + </p> + <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> + <div class="text"> + <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> + <a class="rp" v-if="appearNote.renote != null">RN:</a> + <div class="translation" v-if="translating || translation"> + <MkLoading v-if="translating" mini/> + <div class="translated" v-else> + <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b> + {{ translation.text }} + </div> + </div> + </div> + <div class="files" v-if="appearNote.files.length > 0"> + <XMediaList :media-list="appearNote.files"/> + </div> + <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> + <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> + <button v-if="collapsed" class="fade _button" @click="collapsed = false"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> + </div> + <footer class="footer"> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <button @click="reply()" class="button _button"> + <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> + <template v-else><i class="fas fa-reply"></i></template> + <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> + </button> + <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> + <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> + </button> + <button v-else class="button _button"> + <i class="fas fa-ban"></i> + </button> + <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> + <i class="fas fa-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> + <i class="fas fa-minus"></i> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <i class="fas fa-ellipsis-h"></i> + </button> + </footer> + </div> + </article> +</div> +<div v-else class="muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as mfm from 'mfm-js'; +import { sum } from '@/scripts/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNoteSimple from './note-simple.vue'; +import XReactionsViewer from './reactions-viewer.vue'; +import XMediaList from './media-list.vue'; +import XCwButton from './cw-button.vue'; +import XPoll from './poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNoteSimple, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + replies: [], + showContent: false, + collapsed: false, + isDeleted: false, + muted: false, + translation: null, + translating: false, + }; + }, + + computed: { + rs() { + return this.$store.state.reactions; + }, + keymap(): any { + return { + 'r': () => this.reply(true), + 'e|a|plus': () => this.react(true), + 'q': () => this.renote(true), + 'f|b': this.favorite, + 'delete|ctrl+d': this.del, + 'ctrl+q': this.renoteDirectly, + 'up|k|shift+tab': this.focusBefore, + 'down|j|tab': this.focusAfter, + 'esc': this.blur, + 'm|o': () => this.menu(true), + 's': this.toggleShowContent, + '1': () => this.reactDirectly(this.rs[0]), + '2': () => this.reactDirectly(this.rs[1]), + '3': () => this.reactDirectly(this.rs[2]), + '4': () => this.reactDirectly(this.rs[3]), + '5': () => this.reactDirectly(this.rs[4]), + '6': () => this.reactDirectly(this.rs[5]), + '7': () => this.reactDirectly(this.rs[6]), + '8': () => this.reactDirectly(this.rs[7]), + '9': () => this.reactDirectly(this.rs[8]), + '0': () => this.reactDirectly(this.rs[9]), + }; + }, + + isRenote(): boolean { + return (this.note.renote && + this.note.text == null && + this.note.fileIds.length == 0 && + this.note.poll == null); + }, + + appearNote(): any { + return this.isRenote ? this.note.renote : this.note; + }, + + isMyNote(): boolean { + return this.$i && (this.$i.id === this.appearNote.userId); + }, + + isMyRenote(): boolean { + return this.$i && (this.$i.id === this.note.userId); + }, + + canRenote(): boolean { + return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; + }, + + reactionsCount(): number { + return this.appearNote.reactions + ? sum(Object.values(this.appearNote.reactions)) + : 0; + }, + + urls(): string[] { + if (this.appearNote.text) { + return extractUrlFromMfm(mfm.parse(this.appearNote.text)); + } else { + return null; + } + }, + + showTicker() { + if (this.$store.state.instanceTicker === 'always') return true; + if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; + return false; + } + }, + + async created() { + if (this.$i) { + this.connection = os.stream; + } + + this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( + (this.appearNote.text.split('\n').length > 9) || + (this.appearNote.text.length > 500) + ); + this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); + + // plugin + if (noteViewInterruptors.length > 0) { + let result = this.note; + for (const interruptor of noteViewInterruptors) { + result = await interruptor.handler(JSON.parse(JSON.stringify(result))); + } + this.$emit('update:note', Object.freeze(result)); + } + }, + + mounted() { + this.capture(true); + + if (this.$i) { + this.connection.on('_connected_', this.onStreamConnected); + } + }, + + beforeUnmount() { + this.decapture(true); + + if (this.$i) { + this.connection.off('_connected_', this.onStreamConnected); + } + }, + + methods: { + updateAppearNote(v) { + this.$emit('update:note', Object.freeze(this.isRenote ? { + ...this.note, + renote: { + ...this.note.renote, + ...v + } + } : { + ...this.note, + ...v + })); + }, + + readPromo() { + os.api('promo/read', { + noteId: this.appearNote.id + }); + this.isDeleted = true; + }, + + capture(withHandler = false) { + if (this.$i) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); + if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); + } + }, + + decapture(withHandler = false) { + if (this.$i) { + this.connection.send('un', { + id: this.appearNote.id + }); + if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); + } + }, + + onStreamConnected() { + this.capture(); + }, + + onStreamNoteUpdated(data) { + const { type, id, body } = data; + + if (id !== this.appearNote.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + if (body.emoji) { + const emojis = this.appearNote.emojis || []; + if (!emojis.includes(body.emoji)) { + n.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Increment the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: currentCount + 1 + }; + + if (body.userId === this.$i.id) { + n.myReaction = reaction; + } + + this.updateAppearNote(n); + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (this.appearNote.reactions || {})[reaction] || 0; + + // Decrement the count + n.reactions = { + ...this.appearNote.reactions, + [reaction]: Math.max(0, currentCount - 1) + }; + + if (body.userId === this.$i.id) { + n.myReaction = null; + } + + this.updateAppearNote(n); + break; + } + + case 'pollVoted': { + const choice = body.choice; + + // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) + let n = { + ...this.appearNote, + }; + + const choices = [...this.appearNote.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...(body.userId === this.$i.id ? { + isVoted: true + } : {}) + }; + + n.poll = { + ...this.appearNote.poll, + choices: choices + }; + + this.updateAppearNote(n); + break; + } + + case 'deleted': { + this.isDeleted = true; + break; + } + } + }, + + reply(viaKeyboard = false) { + pleaseLogin(); + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + os.popupMenu([{ + text: this.$ts.renote, + icon: 'fas fa-retweet', + action: () => { + os.api('notes/create', { + renoteId: this.appearNote.id + }); + } + }, { + text: this.$ts.quote, + icon: 'fas fa-quote-right', + action: () => { + os.post({ + renote: this.appearNote, + }); + } + }], this.$refs.renoteButton, { + viaKeyboard + }); + }, + + renoteDirectly() { + os.apiWithDialog('notes/create', { + renoteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.renoted, + }); + }, (e: Error) => { + if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { + os.dialog({ + type: 'error', + text: this.$ts.cantRenote, + }); + } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { + os.dialog({ + type: 'error', + text: this.$ts.cantReRenote, + }); + } + }); + }, + + react(viaKeyboard = false) { + pleaseLogin(); + this.blur(); + reactionPicker.show(this.$refs.reactButton, reaction => { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, () => { + this.focus(); + }); + }, + + reactDirectly(reaction) { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, + + undoReact(note) { + const oldReaction = note.myReaction; + if (!oldReaction) return; + os.api('notes/reactions/delete', { + noteId: note.id + }); + }, + + favorite() { + pleaseLogin(); + os.apiWithDialog('notes/favorites/create', { + noteId: this.appearNote.id + }, undefined, (res: any) => { + os.dialog({ + type: 'success', + text: this.$ts.favorited, + }); + }, (e: Error) => { + if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { + os.dialog({ + type: 'error', + text: this.$ts.alreadyFavorited, + }); + } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { + os.dialog({ + type: 'error', + text: this.$ts.cantFavorite, + }); + } + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.noteDeleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + }); + }, + + delEdit() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteAndEditConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: this.appearNote.id + }); + + os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); + }); + }, + + toggleFavorite(favorite: boolean) { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: this.appearNote.id + }); + }, + + toggleWatch(watch: boolean) { + os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { + noteId: this.appearNote.id + }); + }, + + toggleThreadMute(mute: boolean) { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: this.appearNote.id + }); + }, + + getMenu() { + let menu; + if (this.$i) { + const statePromise = os.api('notes/state', { + noteId: this.appearNote.id + }); + + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined, + { + icon: 'fas fa-share-alt', + text: this.$ts.share, + action: this.share + }, + this.$instance.translatorAvailable ? { + icon: 'fas fa-language', + text: this.$ts.translate, + action: this.translate + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: this.$ts.unfavorite, + action: () => this.toggleFavorite(false) + } : { + icon: 'fas fa-star', + text: this.$ts.favorite, + action: () => this.toggleFavorite(true) + }), + { + icon: 'fas fa-paperclip', + text: this.$ts.clip, + action: () => this.clip() + }, + (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'fas fa-eye-slash', + text: this.$ts.unwatch, + action: () => this.toggleWatch(false) + } : { + icon: 'fas fa-eye', + text: this.$ts.watch, + action: () => this.toggleWatch(true) + }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: this.$ts.unmuteThread, + action: () => this.toggleThreadMute(false) + } : { + icon: 'fas fa-comment-slash', + text: this.$ts.muteThread, + action: () => this.toggleThreadMute(true) + }), + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: 'fas fa-thumbtack', + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: 'fas fa-edit', + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'fas fa-plug', + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } + }, + + menu(viaKeyboard = false) { + os.popupMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(this.focus); + }, + + showRenoteMenu(viaKeyboard = false) { + if (!this.isMyRenote) return; + os.popupMenu([{ + text: this.$ts.unrenote, + icon: 'fas fa-trash-alt', + danger: true, + action: () => { + os.api('notes/delete', { + noteId: this.note.id + }); + this.isDeleted = true; + } + }], this.$refs.renoteTime, { + viaKeyboard: viaKeyboard + }); + }, + + toggleShowContent() { + this.showContent = !this.showContent; + }, + + copyContent() { + copyToClipboard(this.appearNote.text); + os.success(); + }, + + copyLink() { + copyToClipboard(`${url}/notes/${this.appearNote.id}`); + os.success(); + }, + + togglePin(pin: boolean) { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: this.appearNote.id + }, undefined, null, e => { + if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.dialog({ + type: 'error', + text: this.$ts.pinLimitExceeded + }); + } + }); + }, + + async clip() { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'fas fa-plus', + text: this.$ts.createNew, + action: async () => { + const { canceled, result } = await os.form(this.$ts.createNewClip, { + name: { + type: 'string', + label: this.$ts.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: false + } + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); + } + }))], this.$refs.menuButton, { + }).then(this.focus); + }, + + async promote() { + const { canceled, result: days } = await os.dialog({ + title: this.$ts.numberOfDays, + input: { type: 'number' } + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: this.appearNote.id, + expiresAt: Date.now() + (86400000 * days) + }); + }, + + share() { + navigator.share({ + title: this.$t('noteOf', { user: this.appearNote.user.name }), + text: this.appearNote.text, + url: `${url}/notes/${this.appearNote.id}` + }); + }, + + async translate() { + if (this.translation != null) return; + this.translating = true; + const res = await os.api('notes/translate', { + noteId: this.appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + this.translating = false; + this.translation = res; + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.tkcbzcuz { + position: relative; + transition: box-shadow 0.1s ease; + overflow: clip; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + + &:after { + content: ""; + pointer-events: none; + display: block; + position: absolute; + z-index: 10; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: calc(100% - 8px); + height: calc(100% - 8px); + border: dashed 1px var(--focus); + border-radius: var(--radius); + box-sizing: border-box; + } + } + + &:hover > .article > .main > .footer > .button { + opacity: 1; + } + + > .info { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 24px; + font-size: 90%; + white-space: pre; + color: #d28a3f; + + > i { + margin-right: 4px; + } + + > .hide { + margin-left: auto; + color: inherit; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 16px 32px 8px 32px; + line-height: 28px; + white-space: pre; + color: var(--renote); + + > .avatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; + border-radius: 6px; + } + + > i { + margin-right: 4px; + } + + > span { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + + > .name { + font-weight: bold; + } + } + + > .info { + margin-left: auto; + font-size: 0.9em; + + > .time { + flex-shrink: 0; + color: inherit; + + > .dropdownIcon { + margin-right: 4px; + } + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } + } + + > .renote + .article { + padding-top: 8px; + } + + > .article { + display: flex; + padding: 28px 32px 18px; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 14px 8px 0; + width: 58px; + height: 58px; + position: sticky; + top: calc(22px + var(--stickyTop, 0px)); + left: 0; + } + + > .main { + flex: 1; + min-width: 0; + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + &.collapsed { + position: relative; + max-height: 9em; + overflow: hidden; + + > .fade { + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + + > .text { + overflow-wrap: break-word; + + > .reply { + color: var(--accent); + margin-right: 0.5em; + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + + > .translation { + border: solid 0.5px var(--divider); + border-radius: var(--radius); + padding: 12px; + margin-top: 8px; + } + } + + > .url-preview { + margin-top: 8px; + } + + > .poll { + font-size: 80%; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:not(:last-child) { + margin-right: 28px; + } + + &:hover { + color: var(--fgHighlighted); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } + + &.max-width_500px { + font-size: 0.9em; + } + + &.max-width_450px { + > .renote { + padding: 8px 16px 0 16px; + } + + > .info { + padding: 8px 16px 0 16px; + } + + > .article { + padding: 14px 16px 9px; + + > .avatar { + margin: 0 10px 8px 0; + width: 50px; + height: 50px; + top: calc(14px + var(--stickyTop, 0px)); + } + } + } + + &.max-width_350px { + > .article { + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 18px; + } + } + } + } + } + } + + &.max-width_300px { + font-size: 0.825em; + + > .article { + > .avatar { + width: 44px; + height: 44px; + } + + > .main { + > .footer { + > .button { + &:not(:last-child) { + margin-right: 12px; + } + } + } + } + } + } +} + +.muted { + padding: 8px; + text-align: center; + opacity: 0.7; +} +</style> diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue new file mode 100644 index 0000000000..1e7da7a2b0 --- /dev/null +++ b/packages/client/src/components/notes.vue @@ -0,0 +1,130 @@ +<template> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <div class="_fullinfo" v-else-if="empty"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noNotes }}</div> + </div> + + <div v-else class="giivymft" :class="{ noGap }"> + <div v-show="more && reversed" style="margin-bottom: var(--margin);"> + <MkButton style="margin: 0 auto;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> + + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes"> + <XNote class="qtqtichx" :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> + </XList> + + <div v-show="more && !reversed" style="margin-top: var(--margin);"> + <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import XNote from './note.vue'; +import XList from './date-separated-list.vue'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + XNote, XList, MkButton, + }, + + mixins: [ + paging({ + before: (self) => { + self.$emit('before'); + }, + + after: (self, e) => { + self.$emit('after', e); + } + }), + ], + + props: { + pagination: { + required: true + }, + prop: { + type: String, + required: false + }, + noGap: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['before', 'after'], + + computed: { + notes(): any[] { + return this.prop ? this.items.map(item => item[this.prop]) : this.items; + }, + + reversed(): boolean { + return this.pagination.reversed; + } + }, + + methods: { + updated(oldValue, newValue) { + const i = this.notes.findIndex(n => n === oldValue); + if (this.prop) { + this.items[i][this.prop] = newValue; + } else { + this.items[i] = newValue; + } + }, + + focus() { + this.$refs.notes.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.giivymft { + &.noGap { + > .notes { + background: var(--panel); + } + } + + &:not(.noGap) { + > .notes { + background: var(--bg); + + .qtqtichx { + background: var(--panel); + border-radius: var(--radius); + } + } + } +} +</style> diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue new file mode 100644 index 0000000000..ec1efec261 --- /dev/null +++ b/packages/client/src/components/notification-setting-window.vue @@ -0,0 +1,99 @@ +<template> +<XModalWindow ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + @ok="ok()" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.notificationSetting }}</template> + <div class="_monolithic_"> + <div v-if="showGlobalToggle" class="_section"> + <MkSwitch v-model="useGlobalSetting"> + {{ $ts.useGlobalSetting }} + <template #caption>{{ $ts.useGlobalSettingDesc }}</template> + </MkSwitch> + </div> + <div v-if="!useGlobalSetting" class="_section"> + <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo> + <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> + <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkSwitch from './form/switch.vue'; +import MkInfo from './ui/info.vue'; +import MkButton from './ui/button.vue'; +import { notificationTypes } from 'misskey-js'; + +export default defineComponent({ + components: { + XModalWindow, + MkSwitch, + MkInfo, + MkButton + }, + + props: { + includingTypes: { + // TODO: これで型に合わないものを弾いてくれるのかどうか要調査 + type: Array as PropType<typeof notificationTypes[number][]>, + required: false, + default: null, + }, + showGlobalToggle: { + type: Boolean, + required: false, + default: true, + } + }, + + emits: ['done', 'closed'], + + data() { + return { + typesMap: {} as Record<typeof notificationTypes[number], boolean>, + useGlobalSetting: false, + notificationTypes, + }; + }, + + created() { + this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle; + + for (const type of this.notificationTypes) { + this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type); + } + }, + + methods: { + ok() { + const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][]) + .filter(type => this.typesMap[type]); + + this.$emit('done', { includingTypes }); + this.$refs.dialog.close(); + }, + + disableAll() { + for (const type in this.typesMap) { + this.typesMap[type as typeof notificationTypes[number]] = false; + } + }, + + enableAll() { + for (const type in this.typesMap) { + this.typesMap[type as typeof notificationTypes[number]] = true; + } + } + } +}); +</script> diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue new file mode 100644 index 0000000000..b629820043 --- /dev/null +++ b/packages/client/src/components/notification.vue @@ -0,0 +1,362 @@ +<template> +<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }" ref="elRef"> + <div class="head"> + <MkAvatar v-if="notification.user" class="icon" :user="notification.user"/> + <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> + <div class="sub-icon" :class="notification.type"> + <i v-if="notification.type === 'follow'" class="fas fa-plus"></i> + <i v-else-if="notification.type === 'receiveFollowRequest'" class="fas fa-clock"></i> + <i v-else-if="notification.type === 'followRequestAccepted'" class="fas fa-check"></i> + <i v-else-if="notification.type === 'groupInvited'" class="fas fa-id-card-alt"></i> + <i v-else-if="notification.type === 'renote'" class="fas fa-retweet"></i> + <i v-else-if="notification.type === 'reply'" class="fas fa-reply"></i> + <i v-else-if="notification.type === 'mention'" class="fas fa-at"></i> + <i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i> + <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i> + <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> + <XReactionIcon v-else-if="notification.type === 'reaction'" + :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" + :custom-emojis="notification.note.emojis" + :no-style="true" + @touchstart.passive="onReactionMouseover" + @mouseover="onReactionMouseover" + @mouseleave="onReactionMouseleave" + @touchend="onReactionMouseleave" + ref="reactionRef" + /> + </div> + </div> + <div class="tail"> + <header> + <MkA v-if="notification.user" class="name" :to="userPage(notification.user)" v-user-preview="notification.user.id"><MkUserName :user="notification.user"/></MkA> + <span v-else>{{ notification.header }}</span> + <MkTime :time="notification.createdAt" v-if="withTime" class="time"/> + </header> + <MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="fas fa-quote-left"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <i class="fas fa-quote-right"></i> + </MkA> + <MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)"> + <i class="fas fa-quote-left"></i> + <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/> + <i class="fas fa-quote-right"></i> + </MkA> + <MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + </MkA> + <MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> + <i class="fas fa-quote-left"></i> + <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> + <i class="fas fa-quote-right"></i> + </MkA> + <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> + <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span> + <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span> + <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $ts.reject }}</button></div></span> + <span v-if="notification.type === 'app'" class="text"> + <Mfm :text="notification.body" :nowrap="!full"/> + </span> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, onMounted, onUnmounted } from 'vue'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import XReactionIcon from './reaction-icon.vue'; +import MkFollowButton from './follow-button.vue'; +import XReactionTooltip from './reaction-tooltip.vue'; +import notePage from '@/filters/note'; +import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XReactionIcon, MkFollowButton + }, + + props: { + notification: { + type: Object, + required: true, + }, + withTime: { + type: Boolean, + required: false, + default: false, + }, + full: { + type: Boolean, + required: false, + default: false, + }, + }, + + setup(props) { + const elRef = ref<HTMLElement>(null); + const reactionRef = ref(null); + + onMounted(() => { + let readObserver: IntersectionObserver = null; + let connection = null; + + if (!props.notification.isRead) { + readObserver = new IntersectionObserver((entries, observer) => { + if (!entries.some(entry => entry.isIntersecting)) return; + os.stream.send('readNotification', { + id: props.notification.id + }); + entries.map(({ target }) => observer.unobserve(target)); + }); + + readObserver.observe(elRef.value); + + connection = os.stream.useChannel('main'); + connection.on('readAllNotifications', () => readObserver.unobserve(elRef.value)); + } + + onUnmounted(() => { + if (readObserver) readObserver.unobserve(elRef.value); + if (connection) connection.dispose(); + }); + }); + + const followRequestDone = ref(false); + const groupInviteDone = ref(false); + + const acceptFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/accept', { userId: props.notification.user.id }); + }; + + const rejectFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/reject', { userId: props.notification.user.id }); + }; + + const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); + }; + + const rejectGroupInvitation = () => { + groupInviteDone.value = true; + os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); + }; + + let isReactionHovering = false; + let reactionTooltipTimeoutId; + + const onReactionMouseover = () => { + if (isReactionHovering) return; + isReactionHovering = true; + reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300); + }; + + const onReactionMouseleave = () => { + if (!isReactionHovering) return; + isReactionHovering = false; + clearTimeout(reactionTooltipTimeoutId); + closeReactionTooltip(); + }; + + let changeReactionTooltipShowingState: () => void; + + const openReactionTooltip = () => { + closeReactionTooltip(); + if (!isReactionHovering) return; + + const showing = ref(true); + os.popup(XReactionTooltip, { + showing, + reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, + emojis: props.notification.note.emojis, + source: reactionRef.value.$el, + }, {}, 'closed'); + + changeReactionTooltipShowingState = () => { + showing.value = false; + }; + }; + + const closeReactionTooltip = () => { + if (changeReactionTooltipShowingState != null) { + changeReactionTooltipShowingState(); + changeReactionTooltipShowingState = null; + } + }; + + return { + getNoteSummary: (text: string) => getNoteSummary(text, i18n.locale), + followRequestDone, + groupInviteDone, + notePage, + userPage, + acceptFollowRequest, + rejectFollowRequest, + acceptGroupInvitation, + rejectGroupInvitation, + onReactionMouseover, + onReactionMouseleave, + elRef, + reactionRef, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.qglefbjs { + position: relative; + box-sizing: border-box; + padding: 24px 32px; + font-size: 0.9em; + overflow-wrap: break-word; + display: flex; + contain: content; + + &.max-width_600px { + padding: 16px; + font-size: 0.9em; + } + + &.max-width_500px { + padding: 12px; + font-size: 0.8em; + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > .head { + position: sticky; + top: 0; + flex-shrink: 0; + width: 42px; + height: 42px; + margin-right: 8px; + + > .icon { + display: block; + width: 100%; + height: 100%; + border-radius: 6px; + } + + > .sub-icon { + position: absolute; + z-index: 1; + bottom: -2px; + right: -2px; + width: 20px; + height: 20px; + box-sizing: border-box; + border-radius: 100%; + background: var(--panel); + box-shadow: 0 0 0 3px var(--panel); + font-size: 12px; + text-align: center; + + &:empty { + display: none; + } + + > * { + color: #fff; + width: 100%; + height: 100%; + } + + &.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited { + padding: 3px; + background: #36aed2; + pointer-events: none; + } + + &.renote { + padding: 3px; + background: #36d298; + pointer-events: none; + } + + &.quote { + padding: 3px; + background: #36d298; + pointer-events: none; + } + + &.reply { + padding: 3px; + background: #007aff; + pointer-events: none; + } + + &.mention { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + + &.pollVote { + padding: 3px; + background: #88a6b7; + pointer-events: none; + } + } + } + + > .tail { + flex: 1; + min-width: 0; + + > header { + display: flex; + align-items: baseline; + white-space: nowrap; + + > .name { + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + overflow: hidden; + } + + > .time { + margin-left: auto; + font-size: 0.9em; + } + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > i { + vertical-align: super; + font-size: 50%; + opacity: 0.5; + } + + > i:first-child { + margin-right: 4px; + } + + > i:last-child { + margin-left: 4px; + } + } + } +} +</style> diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue new file mode 100644 index 0000000000..4ebb12c44b --- /dev/null +++ b/packages/client/src/components/notifications.vue @@ -0,0 +1,159 @@ +<template> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <p class="mfcuwfyp" v-else-if="empty">{{ $ts.noNotifications }}</p> + + <div v-else> + <XList class="elsfgstc" :items="items" v-slot="{ item: notification }" :no-gap="true"> + <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/> + <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> + </XList> + + <MkButton primary style="margin: var(--margin) auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType, markRaw } from 'vue'; +import paging from '@/scripts/paging'; +import XNotification from './notification.vue'; +import XList from './date-separated-list.vue'; +import XNote from './note.vue'; +import { notificationTypes } from 'misskey-js'; +import * as os from '@/os'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + XNotification, + XList, + XNote, + MkButton, + }, + + mixins: [ + paging({}), + ], + + props: { + includeTypes: { + type: Array as PropType<typeof notificationTypes[number][]>, + required: false, + default: null, + }, + unreadOnly: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + connection: null, + pagination: { + endpoint: 'i/notifications', + limit: 10, + params: () => ({ + includeTypes: this.allIncludeTypes || undefined, + unreadOnly: this.unreadOnly, + }) + }, + }; + }, + + computed: { + allIncludeTypes() { + return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x)); + } + }, + + watch: { + includeTypes: { + handler() { + this.reload(); + }, + deep: true + }, + unreadOnly: { + handler() { + this.reload(); + }, + }, + // TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、 + // mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す + '$i.mutingNotificationTypes': { + handler() { + if (this.includeTypes === null) { + this.reload(); + } + }, + deep: true + } + }, + + mounted() { + this.connection = markRaw(os.stream.useChannel('main')); + this.connection.on('notification', this.onNotification); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + onNotification(notification) { + const isMuted = !this.allIncludeTypes.includes(notification.type); + if (isMuted || document.visibilityState === 'visible') { + os.stream.send('readNotification', { + id: notification.id + }); + } + + if (!isMuted) { + this.prepend({ + ...notification, + isRead: document.visibilityState === 'visible' + }); + } + }, + + noteUpdated(oldValue, newValue) { + const i = this.items.findIndex(n => n.note === oldValue); + this.items[i] = { + ...this.items[i], + note: newValue + }; + }, + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.mfcuwfyp { + margin: 0; + padding: 16px; + text-align: center; + color: var(--fg); +} + +.elsfgstc { + background: var(--panel); +} +</style> diff --git a/packages/client/src/components/number-diff.vue b/packages/client/src/components/number-diff.vue new file mode 100644 index 0000000000..9889c97ec3 --- /dev/null +++ b/packages/client/src/components/number-diff.vue @@ -0,0 +1,47 @@ +<template> +<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }"> + <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot> +</span> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import number from '@/filters/number'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: true + }, + }, + + setup(props) { + const isPlus = computed(() => props.value > 0); + const isMinus = computed(() => props.value < 0); + const isZero = computed(() => props.value === 0); + return { + isPlus, + isMinus, + isZero, + number, + }; + } +}); +</script> + +<style lang="scss" scoped> +.ceaaebcd { + &.isPlus { + color: var(--success); + } + + &.isMinus { + color: var(--error); + } + + &.isZero { + opacity: 0.5; + } +} +</style> diff --git a/packages/client/src/components/page-preview.vue b/packages/client/src/components/page-preview.vue new file mode 100644 index 0000000000..05df1dc16e --- /dev/null +++ b/packages/client/src/components/page-preview.vue @@ -0,0 +1,162 @@ +<template> +<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1"> + <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> + <article> + <header> + <h1 :title="page.title">{{ page.title }}</h1> + </header> + <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> + <footer> + <img class="icon" :src="page.user.avatarUrl"/> + <p>{{ userName(page.user) }}</p> + </footer> + </article> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { userName } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + page: { + type: Object, + required: true + }, + }, + methods: { + userName + } +}); +</script> + +<style lang="scss" scoped> +.vhpxefrj { + display: block; + + &:hover { + text-decoration: none; + color: var(--accent); + } + + > .thumbnail { + width: 100%; + height: 200px; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + color: var(--urlPreviewTitle); + } + } + + > p { + margin: 0; + color: var(--urlPreviewText); + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + @media (max-width: 700px) { + > .thumbnail { + position: relative; + width: 100%; + height: 100px; + + & + article { + left: 0; + width: 100%; + } + } + } + + @media (max-width: 550px) { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + + @media (max-width: 500px) { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + } +} + +</style> diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue new file mode 100644 index 0000000000..b6be114cd7 --- /dev/null +++ b/packages/client/src/components/page-window.vue @@ -0,0 +1,167 @@ +<template> +<XWindow ref="window" + :initial-width="500" + :initial-height="500" + :can-resize="true" + :close-button="true" + :contextmenu="contextmenu" + @closed="$emit('closed')" +> + <template #header> + <template v-if="pageInfo"> + <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageInfo.title }}</span> + </template> + </template> + <template #headerLeft> + <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> + </template> + <div class="yrolvcoq"> + <MkStickyContainer> + <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> + <component :is="component" v-bind="props" :ref="changePage"/> + </MkStickyContainer> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XWindow from '@/components/ui/window.vue'; +import { popout } from '@/scripts/popout'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XWindow, + }, + + inject: { + sideViewHook: { + default: null + } + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + }, + shouldHeaderThin: true, + }; + }, + + props: { + initialPath: { + type: String, + required: true, + }, + initialComponent: { + type: Object, + required: true, + }, + initialProps: { + type: Object, + required: false, + default: () => {}, + }, + }, + + emits: ['closed'], + + data() { + return { + pageInfo: null, + path: this.initialPath, + component: this.initialComponent, + props: this.initialProps, + history: [], + }; + }, + + computed: { + url(): string { + return url + this.path; + }, + + contextmenu() { + return [{ + type: 'label', + text: this.path, + }, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: this.expand + }, this.sideViewHook ? { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.sideViewHook(this.path); + this.$refs.window.close(); + } + } : undefined, { + icon: 'fas fa-external-link-alt', + text: this.$ts.popout, + action: this.popout + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.$refs.window.close(); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }]; + }, + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + navigate(path, record = true) { + if (record) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + close() { + this.$refs.window.close(); + }, + + expand() { + this.$router.push(this.path); + this.$refs.window.close(); + }, + + popout() { + popout(this.path, this.$el); + this.$refs.window.close(); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.yrolvcoq { + min-height: 100%; +} +</style> diff --git a/packages/client/src/components/page/page.block.vue b/packages/client/src/components/page/page.block.vue new file mode 100644 index 0000000000..54b8b30276 --- /dev/null +++ b/packages/client/src/components/page/page.block.vue @@ -0,0 +1,44 @@ +<template> +<component :is="'x-' + block.type" :block="block" :hpml="hpml" :key="block.id" :h="h"/> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import XText from './page.text.vue'; +import XSection from './page.section.vue'; +import XImage from './page.image.vue'; +import XButton from './page.button.vue'; +import XNumberInput from './page.number-input.vue'; +import XTextInput from './page.text-input.vue'; +import XTextareaInput from './page.textarea-input.vue'; +import XSwitch from './page.switch.vue'; +import XIf from './page.if.vue'; +import XTextarea from './page.textarea.vue'; +import XPost from './page.post.vue'; +import XCounter from './page.counter.vue'; +import XRadioButton from './page.radio-button.vue'; +import XCanvas from './page.canvas.vue'; +import XNote from './page.note.vue'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { Block } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote + }, + props: { + block: { + type: Object as PropType<Block>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + }, + h: { + type: Number, + required: true + } + }, +}); +</script> diff --git a/packages/client/src/components/page/page.button.vue b/packages/client/src/components/page/page.button.vue new file mode 100644 index 0000000000..51da84bd49 --- /dev/null +++ b/packages/client/src/components/page/page.button.vue @@ -0,0 +1,66 @@ +<template> +<div> + <MkButton class="kudkigyw" @click="click()" :primary="block.primary">{{ hpml.interpolate(block.text) }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType, unref } from 'vue'; +import MkButton from '../ui/button.vue'; +import * as os from '@/os'; +import { ButtonBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkButton + }, + props: { + block: { + type: Object as PropType<ButtonBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + methods: { + click() { + if (this.block.action === 'dialog') { + this.hpml.eval(); + os.dialog({ + text: this.hpml.interpolate(this.block.content) + }); + } else if (this.block.action === 'resetRandom') { + this.hpml.updateRandomSeed(Math.random()); + this.hpml.eval(); + } else if (this.block.action === 'pushEvent') { + os.api('page-push', { + pageId: this.hpml.page.id, + event: this.block.event, + ...(this.block.var ? { + var: unref(this.hpml.vars)[this.block.var] + } : {}) + }); + + os.dialog({ + type: 'success', + text: this.hpml.interpolate(this.block.message) + }); + } else if (this.block.action === 'callAiScript') { + this.hpml.callAiScript(this.block.fn); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 200px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.canvas.vue b/packages/client/src/components/page/page.canvas.vue new file mode 100644 index 0000000000..8f49b88e5e --- /dev/null +++ b/packages/client/src/components/page/page.canvas.vue @@ -0,0 +1,49 @@ +<template> +<div class="ysrxegms"> + <canvas ref="canvas" :width="block.width" :height="block.height"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; +import * as os from '@/os'; +import { CanvasBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + props: { + block: { + type: Object as PropType<CanvasBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const canvas: Ref<any> = ref(null); + + onMounted(() => { + props.hpml.registerCanvas(props.block.name, canvas.value); + }); + + return { + canvas + }; + } +}); +</script> + +<style lang="scss" scoped> +.ysrxegms { + display: inline-block; + vertical-align: bottom; + overflow: auto; + max-width: 100%; + + > canvas { + display: block; + } +} +</style> diff --git a/packages/client/src/components/page/page.counter.vue b/packages/client/src/components/page/page.counter.vue new file mode 100644 index 0000000000..b1af8954b0 --- /dev/null +++ b/packages/client/src/components/page/page.counter.vue @@ -0,0 +1,52 @@ +<template> +<div> + <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkButton from '../ui/button.vue'; +import * as os from '@/os'; +import { CounterVarBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkButton + }, + props: { + block: { + type: Object as PropType<CounterVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function click() { + props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1)); + props.hpml.eval(); + } + + return { + click + }; + } +}); +</script> + +<style lang="scss" scoped> +.llumlmnx { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.if.vue b/packages/client/src/components/page/page.if.vue new file mode 100644 index 0000000000..ec25332db0 --- /dev/null +++ b/packages/client/src/components/page/page.if.vue @@ -0,0 +1,31 @@ +<template> +<div v-show="hpml.vars.value[block.var]"> + <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h"/> +</div> +</template> + +<script lang="ts"> +import { IfBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineComponent, defineAsyncComponent, PropType } from 'vue'; + +export default defineComponent({ + components: { + XBlock: defineAsyncComponent(() => import('./page.block.vue')) + }, + props: { + block: { + type: Object as PropType<IfBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + }, + h: { + type: Number, + required: true + } + }, +}); +</script> diff --git a/packages/client/src/components/page/page.image.vue b/packages/client/src/components/page/page.image.vue new file mode 100644 index 0000000000..04ce74bd7c --- /dev/null +++ b/packages/client/src/components/page/page.image.vue @@ -0,0 +1,40 @@ +<template> +<div class="lzyxtsnt"> + <img v-if="image" :src="image.url"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import * as os from '@/os'; +import { ImageBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + props: { + block: { + type: Object as PropType<ImageBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId); + + return { + image + }; + } +}); +</script> + +<style lang="scss" scoped> +.lzyxtsnt { + > img { + max-width: 100%; + } +} +</style> diff --git a/packages/client/src/components/page/page.note.vue b/packages/client/src/components/page/page.note.vue new file mode 100644 index 0000000000..925844c1bd --- /dev/null +++ b/packages/client/src/components/page/page.note.vue @@ -0,0 +1,47 @@ +<template> +<div class="voxdxuby"> + <XNote v-if="note && !block.detailed" v-model:note="note" :key="note.id + ':normal'"/> + <XNoteDetailed v-if="note && block.detailed" v-model:note="note" :key="note.id + ':detail'"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; +import XNote from '@/components/note.vue'; +import XNoteDetailed from '@/components/note-detailed.vue'; +import * as os from '@/os'; +import { NoteBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + XNote, + XNoteDetailed, + }, + props: { + block: { + type: Object as PropType<NoteBlock>, + required: true + } + }, + setup(props, ctx) { + const note: Ref<Record<string, any> | null> = ref(null); + + onMounted(() => { + os.api('notes/show', { noteId: props.block.note }) + .then(result => { + note.value = result; + }); + }); + + return { + note + }; + } +}); +</script> + +<style lang="scss" scoped> +.voxdxuby { + margin: 1em 0; +} +</style> diff --git a/packages/client/src/components/page/page.number-input.vue b/packages/client/src/components/page/page.number-input.vue new file mode 100644 index 0000000000..b5120d0f85 --- /dev/null +++ b/packages/client/src/components/page/page.number-input.vue @@ -0,0 +1,55 @@ +<template> +<div> + <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="number"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkInput> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkInput from '../form/input.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { NumberInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkInput + }, + props: { + block: { + type: Object as PropType<NumberInputVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.post.vue b/packages/client/src/components/page/page.post.vue new file mode 100644 index 0000000000..1b86ea1ab9 --- /dev/null +++ b/packages/client/src/components/page/page.post.vue @@ -0,0 +1,109 @@ +<template> +<div class="ngbfujlo"> + <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea> + <MkButton class="button" primary @click="post()" :disabled="posting || posted"> + <i v-if="posted" class="fas fa-check"></i> + <i v-else class="fas fa-paper-plane"></i> + </MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; +import MkButton from '../ui/button.vue'; +import { apiUrl } from '@/config'; +import * as os from '@/os'; +import { PostBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + MkTextarea, + MkButton, + }, + props: { + block: { + type: Object as PropType<PostBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + posted: false, + posting: false, + }; + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true + } + }, + methods: { + upload() { + const promise = new Promise((ok) => { + const canvas = this.hpml.canvases[this.block.canvasId]; + canvas.toBlob(blob => { + const data = new FormData(); + data.append('file', blob); + data.append('i', this.$i.token); + if (this.$store.state.uploadFolder) { + data.append('folderId', this.$store.state.uploadFolder); + } + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + ok(f); + }) + }); + }); + os.promiseDialog(promise); + return promise; + }, + async post() { + this.posting = true; + const file = this.block.attachCanvasImage ? await this.upload() : null; + os.apiWithDialog('notes/create', { + text: this.text === '' ? null : this.text, + fileIds: file ? [file.id] : undefined, + }).then(() => { + this.posted = true; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.ngbfujlo { + position: relative; + padding: 32px; + border-radius: 6px; + box-shadow: 0 2px 8px var(--shadow); + z-index: 1; + + > .button { + margin-top: 32px; + } + + @media (max-width: 600px) { + padding: 16px; + + > .button { + margin-top: 16px; + } + } +} +</style> diff --git a/packages/client/src/components/page/page.radio-button.vue b/packages/client/src/components/page/page.radio-button.vue new file mode 100644 index 0000000000..4d3c03291e --- /dev/null +++ b/packages/client/src/components/page/page.radio-button.vue @@ -0,0 +1,45 @@ +<template> +<div> + <div>{{ hpml.interpolate(block.title) }}</div> + <MkRadio v-for="item in block.values" :modelValue="value" @update:modelValue="updateValue($event)" :value="item" :key="item">{{ item }}</MkRadio> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkRadio from '../form/radio.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { RadioButtonVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + block: { + type: Object as PropType<RadioButtonVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue: string) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> diff --git a/packages/client/src/components/page/page.section.vue b/packages/client/src/components/page/page.section.vue new file mode 100644 index 0000000000..d32f5dc732 --- /dev/null +++ b/packages/client/src/components/page/page.section.vue @@ -0,0 +1,60 @@ +<template> +<section class="sdgxphyu"> + <component :is="'h' + h">{{ block.title }}</component> + + <div class="children"> + <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h + 1"/> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, PropType } from 'vue'; +import * as os from '@/os'; +import { SectionBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; + +export default defineComponent({ + components: { + XBlock: defineAsyncComponent(() => import('./page.block.vue')) + }, + props: { + block: { + type: Object as PropType<SectionBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + }, + h: { + required: true + } + }, +}); +</script> + +<style lang="scss" scoped> +.sdgxphyu { + margin: 1.5em 0; + + > h2 { + font-size: 1.35em; + margin: 0 0 0.5em 0; + } + + > h3 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > h4 { + font-size: 1em; + margin: 0 0 0.5em 0; + } + + > .children { + //padding 16px + } +} +</style> diff --git a/packages/client/src/components/page/page.switch.vue b/packages/client/src/components/page/page.switch.vue new file mode 100644 index 0000000000..1ece88157f --- /dev/null +++ b/packages/client/src/components/page/page.switch.vue @@ -0,0 +1,55 @@ +<template> +<div class="hkcxmtwj"> + <MkSwitch :model-value="value" @update:modelValue="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkSwitch from '../form/switch.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { SwitchVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkSwitch + }, + props: { + block: { + type: Object as PropType<SwitchVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue: boolean) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> + +<style lang="scss" scoped> +.hkcxmtwj { + display: inline-block; + margin: 16px auto; + + & + .hkcxmtwj { + margin-left: 16px; + } +} +</style> diff --git a/packages/client/src/components/page/page.text-input.vue b/packages/client/src/components/page/page.text-input.vue new file mode 100644 index 0000000000..e4d3f6039a --- /dev/null +++ b/packages/client/src/components/page/page.text-input.vue @@ -0,0 +1,55 @@ +<template> +<div> + <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="text"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkInput> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkInput from '../form/input.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { TextInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkInput + }, + props: { + block: { + type: Object as PropType<TextInputVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> + +<style lang="scss" scoped> +.kudkigyw { + display: inline-block; + min-width: 300px; + max-width: 450px; + margin: 8px 0; +} +</style> diff --git a/packages/client/src/components/page/page.text.vue b/packages/client/src/components/page/page.text.vue new file mode 100644 index 0000000000..7dd41ed869 --- /dev/null +++ b/packages/client/src/components/page/page.text.vue @@ -0,0 +1,68 @@ +<template> +<div class="mrdgzndn"> + <Mfm :text="text" :is-note="false" :i="$i" :key="text"/> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" class="url"/> +</div> +</template> + +<script lang="ts"> +import { TextBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineAsyncComponent, defineComponent, PropType } from 'vue'; +import * as mfm from 'mfm-js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + }, + props: { + block: { + type: Object as PropType<TextBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + }; + }, + computed: { + urls(): string[] { + if (this.text) { + return extractUrlFromMfm(mfm.parse(this.text)); + } else { + return []; + } + } + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true + } + }, +}); +</script> + +<style lang="scss" scoped> +.mrdgzndn { + &:not(:first-child) { + margin-top: 0.5em; + } + + &:not(:last-child) { + margin-bottom: 0.5em; + } + + > .url { + margin: 0.5em 0; + } +} +</style> diff --git a/packages/client/src/components/page/page.textarea-input.vue b/packages/client/src/components/page/page.textarea-input.vue new file mode 100644 index 0000000000..6e082b2bef --- /dev/null +++ b/packages/client/src/components/page/page.textarea-input.vue @@ -0,0 +1,47 @@ +<template> +<div> + <MkTextarea :model-value="value" @update:modelValue="updateValue($event)"> + <template #label>{{ hpml.interpolate(block.text) }}</template> + </MkTextarea> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; +import * as os from '@/os'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { HpmlTextInput } from '@/scripts/hpml'; +import { TextInputVarBlock } from '@/scripts/hpml/block'; + +export default defineComponent({ + components: { + MkTextarea + }, + props: { + block: { + type: Object as PropType<TextInputVarBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + setup(props, ctx) { + const value = computed(() => { + return props.hpml.vars.value[props.block.name]; + }); + + function updateValue(newValue) { + props.hpml.updatePageVar(props.block.name, newValue); + props.hpml.eval(); + } + + return { + value, + updateValue + }; + } +}); +</script> diff --git a/packages/client/src/components/page/page.textarea.vue b/packages/client/src/components/page/page.textarea.vue new file mode 100644 index 0000000000..5b4ee2b452 --- /dev/null +++ b/packages/client/src/components/page/page.textarea.vue @@ -0,0 +1,39 @@ +<template> +<MkTextarea :model-value="text" readonly></MkTextarea> +</template> + +<script lang="ts"> +import { TextBlock } from '@/scripts/hpml/block'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { defineComponent, PropType } from 'vue'; +import MkTextarea from '../form/textarea.vue'; + +export default defineComponent({ + components: { + MkTextarea + }, + props: { + block: { + type: Object as PropType<TextBlock>, + required: true + }, + hpml: { + type: Object as PropType<Hpml>, + required: true + } + }, + data() { + return { + text: this.hpml.interpolate(this.block.text), + }; + }, + watch: { + 'hpml.vars': { + handler() { + this.text = this.hpml.interpolate(this.block.text); + }, + deep: true + } + } +}); +</script> diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue new file mode 100644 index 0000000000..6d1c419a40 --- /dev/null +++ b/packages/client/src/components/page/page.vue @@ -0,0 +1,86 @@ +<template> +<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }" v-if="hpml"> + <XBlock v-for="child in page.content" :block="child" :hpml="hpml" :key="child.id" :h="2"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue'; +import { parse } from '@syuilo/aiscript'; +import XBlock from './page.block.vue'; +import { Hpml } from '@/scripts/hpml/evaluator'; +import { url } from '@/config'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + XBlock + }, + props: { + page: { + type: Object as PropType<Record<string, any>>, + required: true + }, + }, + setup(props, ctx) { + + const hpml = new Hpml(props.page, { + randomSeed: Math.random(), + visitor: $i, + url: url, + enableAiScript: !defaultStore.state.disablePagesScript + }); + + onMounted(() => { + nextTick(() => { + if (props.page.script && hpml.aiscript) { + let ast; + try { + ast = parse(props.page.script); + } catch (e) { + console.error(e); + /*os.dialog({ + type: 'error', + text: 'Syntax error :(' + });*/ + return; + } + hpml.aiscript.exec(ast).then(() => { + hpml.eval(); + }).catch(e => { + console.error(e); + /*os.dialog({ + type: 'error', + text: e + });*/ + }); + } else { + hpml.eval(); + } + }); + onUnmounted(() => { + if (hpml.aiscript) hpml.aiscript.abort(); + }); + }); + + return { + hpml, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.iroscrza { + &.serif { + > div { + font-family: serif; + } + } + + &.center { + text-align: center; + } +} +</style> diff --git a/packages/client/src/components/particle.vue b/packages/client/src/components/particle.vue new file mode 100644 index 0000000000..d82705c1e8 --- /dev/null +++ b/packages/client/src/components/particle.vue @@ -0,0 +1,114 @@ +<template> +<div class="vswabwbm" :style="{ top: `${y - 64}px`, left: `${x - 64}px` }" :class="{ active }"> + <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> + <circle fill="none" cx="64" cy="64"> + <animate attributeName="r" + begin="0s" dur="0.5s" + values="4; 32" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> + <animate attributeName="stroke-width" + begin="0s" dur="0.5s" + values="16; 0" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + </circle> + <g fill="none" fill-rule="evenodd"> + <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color"> + <animate attributeName="r" + begin="0s" dur="0.8s" + :values="`${particle.size}; 0`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.165, 0.84, 0.44, 1" + repeatCount="1" + /> + <animate attributeName="cx" + begin="0s" dur="0.8s" + :values="`${particle.xA}; ${particle.xB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + <animate attributeName="cy" + begin="0s" dur="0.8s" + :values="`${particle.yA}; ${particle.yB}`" + calcMode="spline" + keyTimes="0; 1" + keySplines="0.3, 0.61, 0.355, 1" + repeatCount="1" + /> + </circle> + </g> + </svg> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + x: { + type: Number, + required: true + }, + y: { + type: Number, + required: true + } + }, + emits: ['end'], + data() { + const particles = []; + const origin = 64; + const colors = ['#FF1493', '#00FFFF', '#FFE202']; + + for (let i = 0; i < 12; i++) { + const angle = Math.random() * (Math.PI * 2); + const pos = Math.random() * 16; + const velocity = 16 + (Math.random() * 48); + particles.push({ + size: 4 + (Math.random() * 8), + xA: origin + (Math.sin(angle) * pos), + yA: origin + (Math.cos(angle) * pos), + xB: origin + (Math.sin(angle) * (pos + velocity)), + yB: origin + (Math.cos(angle) * (pos + velocity)), + color: colors[Math.floor(Math.random() * colors.length)] + }); + } + + return { + particles + }; + }, + mounted() { + setTimeout(() => { + this.$emit('end'); + }, 1100); + } +}); +</script> + +<style lang="scss" scoped> +.vswabwbm { + pointer-events: none; + position: fixed; + z-index: 1000000; + width: 128px; + height: 128px; + + > svg { + > circle { + stroke: var(--accent); + } + } +} +</style> diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/poll-editor.vue new file mode 100644 index 0000000000..aa213cfe49 --- /dev/null +++ b/packages/client/src/components/poll-editor.vue @@ -0,0 +1,251 @@ +<template> +<div class="zmdxowus"> + <p class="caution" v-if="choices.length < 2"> + <i class="fas fa-exclamation-triangle"></i>{{ $ts._poll.noOnlyOneChoice }} + </p> + <ul ref="choices"> + <li v-for="(choice, i) in choices" :key="i"> + <MkInput class="input" :model-value="choice" @update:modelValue="onInput(i, $event)" :placeholder="$t('_poll.choiceN', { n: i + 1 })"> + </MkInput> + <button @click="remove(i)" class="_button"> + <i class="fas fa-times"></i> + </button> + </li> + </ul> + <MkButton class="add" v-if="choices.length < 10" @click="add">{{ $ts.add }}</MkButton> + <MkButton class="add" v-else disabled>{{ $ts._poll.noMore }}</MkButton> + <section> + <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch> + <div> + <MkSelect v-model="expiration"> + <template #label>{{ $ts._poll.expiration }}</template> + <option value="infinite">{{ $ts._poll.infinite }}</option> + <option value="at">{{ $ts._poll.at }}</option> + <option value="after">{{ $ts._poll.after }}</option> + </MkSelect> + <section v-if="expiration === 'at'"> + <MkInput v-model="atDate" type="date" class="input"> + <template #label>{{ $ts._poll.deadlineDate }}</template> + </MkInput> + <MkInput v-model="atTime" type="time" class="input"> + <template #label>{{ $ts._poll.deadlineTime }}</template> + </MkInput> + </section> + <section v-if="expiration === 'after'"> + <MkInput v-model="after" type="number" class="input"> + <template #label>{{ $ts._poll.duration }}</template> + </MkInput> + <MkSelect v-model="unit"> + <option value="second">{{ $ts._time.second }}</option> + <option value="minute">{{ $ts._time.minute }}</option> + <option value="hour">{{ $ts._time.hour }}</option> + <option value="day">{{ $ts._time.day }}</option> + </MkSelect> + </section> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { addTime } from '@/scripts/time'; +import { formatDateTimeString } from '@/scripts/format-time-string'; +import MkInput from './form/input.vue'; +import MkSelect from './form/select.vue'; +import MkSwitch from './form/switch.vue'; +import MkButton from './ui/button.vue'; + +export default defineComponent({ + components: { + MkInput, + MkSelect, + MkSwitch, + MkButton, + }, + + props: { + poll: { + type: Object, + required: true + } + }, + + emits: ['updated'], + + data() { + return { + choices: this.poll.choices, + multiple: this.poll.multiple, + expiration: 'infinite', + atDate: formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'), + atTime: '00:00', + after: 0, + unit: 'second', + }; + }, + + watch: { + choices: { + handler() { + this.$emit('updated', this.get()); + }, + deep: true + }, + multiple: { + handler() { + this.$emit('updated', this.get()); + }, + }, + expiration: { + handler() { + this.$emit('updated', this.get()); + }, + }, + atDate: { + handler() { + this.$emit('updated', this.get()); + }, + }, + after: { + handler() { + this.$emit('updated', this.get()); + }, + }, + unit: { + handler() { + this.$emit('updated', this.get()); + }, + }, + }, + + created() { + const poll = this.poll; + if (poll.expiresAt) { + this.expiration = 'at'; + this.atDate = this.atTime = poll.expiresAt; + } else if (typeof poll.expiredAfter === 'number') { + this.expiration = 'after'; + this.after = poll.expiredAfter / 1000; + } else { + this.expiration = 'infinite'; + } + }, + + methods: { + onInput(i, e) { + this.choices[i] = e; + }, + + add() { + this.choices.push(''); + this.$nextTick(() => { + // TODO + //(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); + }); + }, + + remove(i) { + this.choices = this.choices.filter((_, _i) => _i != i); + }, + + get() { + const at = () => { + return new Date(`${this.atDate} ${this.atTime}`).getTime(); + }; + + const after = () => { + let base = parseInt(this.after); + switch (this.unit) { + case 'day': base *= 24; + case 'hour': base *= 60; + case 'minute': base *= 60; + case 'second': return base *= 1000; + default: return null; + } + }; + + return { + choices: this.choices, + multiple: this.multiple, + ...( + this.expiration === 'at' ? { expiresAt: at() } : + this.expiration === 'after' ? { expiredAfter: after() } : {} + ) + }; + }, + } +}); +</script> + +<style lang="scss" scoped> +.zmdxowus { + padding: 8px; + + > .caution { + margin: 0 0 8px 0; + font-size: 0.8em; + color: #f00; + + > i { + margin-right: 4px; + } + } + + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: flex; + margin: 8px 0; + padding: 0; + width: 100%; + + > .input { + flex: 1; + margin-top: 16px; + margin-bottom: 0; + } + + > button { + width: 32px; + padding: 4px 0; + } + } + } + + > .add { + margin: 8px 0 0 0; + z-index: 1; + } + + > section { + margin: 16px 0 -16px 0; + + > div { + margin: 0 8px; + + &:last-child { + flex: 1 0 auto; + + > section { + align-items: center; + display: flex; + margin: -32px 0 0; + + > &:first-child { + margin-right: 16px; + } + + > .input { + flex: 1 0 auto; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue new file mode 100644 index 0000000000..049fe3a435 --- /dev/null +++ b/packages/client/src/components/poll.vue @@ -0,0 +1,174 @@ +<template> +<div class="tivcixzd" :class="{ done: closed || isVoted }"> + <ul> + <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }"> + <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> + <span> + <template v-if="choice.isVoted"><i class="fas fa-check"></i></template> + <Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> + <span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> + </span> + </li> + </ul> + <p v-if="!readOnly"> + <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> + <span> · </span> + <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $ts._poll.vote : $ts._poll.showResult }}</a> + <span v-if="isVoted">{{ $ts._poll.voted }}</span> + <span v-else-if="closed">{{ $ts._poll.closed }}</span> + <span v-if="remaining > 0"> · {{ timer }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { sum } from '@/scripts/array'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + note: { + type: Object, + required: true + }, + readOnly: { + type: Boolean, + required: false, + default: false, + } + }, + data() { + return { + remaining: -1, + showResult: false, + }; + }, + computed: { + poll(): any { + return this.note.poll; + }, + total(): number { + return sum(this.poll.choices.map(x => x.votes)); + }, + closed(): boolean { + return !this.remaining; + }, + timer(): string { + return this.$t( + this.remaining >= 86400 ? '_poll.remainingDays' : + this.remaining >= 3600 ? '_poll.remainingHours' : + this.remaining >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { + s: Math.floor(this.remaining % 60), + m: Math.floor(this.remaining / 60) % 60, + h: Math.floor(this.remaining / 3600) % 24, + d: Math.floor(this.remaining / 86400) + }); + }, + isVoted(): boolean { + return !this.poll.multiple && this.poll.choices.some(c => c.isVoted); + } + }, + created() { + this.showResult = this.readOnly || this.isVoted; + + if (this.note.poll.expiresAt) { + const update = () => { + if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000)) + requestAnimationFrame(update); + else + this.showResult = true; + }; + + update(); + } + }, + methods: { + toggleShowResult() { + this.showResult = !this.showResult; + }, + vote(id) { + if (this.readOnly || this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; + os.api('notes/polls/vote', { + noteId: this.note.id, + choice: id + }).then(() => { + if (!this.showResult) this.showResult = !this.poll.multiple; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.tivcixzd { + > ul { + display: block; + margin: 0; + padding: 0; + list-style: none; + + > li { + display: block; + position: relative; + margin: 4px 0; + padding: 4px 8px; + border: solid 0.5px var(--divider); + border-radius: 4px; + overflow: hidden; + cursor: pointer; + + &:hover { + background: rgba(#000, 0.05); + } + + &:active { + background: rgba(#000, 0.1); + } + + > .backdrop { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--accent); + transition: width 1s ease; + } + + > span { + position: relative; + + > i { + margin-right: 4px; + } + + > .votes { + margin-left: 4px; + } + } + } + } + + > p { + color: var(--fg); + + a { + color: inherit; + } + } + + &.done { + > ul > li { + cursor: default; + + &:hover { + background: transparent; + } + + &:active { + background: transparent; + } + } + } +} +</style> diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue new file mode 100644 index 0000000000..dff0dec21e --- /dev/null +++ b/packages/client/src/components/post-form-attaches.vue @@ -0,0 +1,193 @@ +<template> +<div class="skeikyzd" v-show="files.length != 0"> + <XDraggable class="files" v-model="_files" item-key="id" animation="150" delay="100" delay-on-touch-only="true"> + <template #item="{element}"> + <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> + <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> + <div class="sensitive" v-if="element.isSensitive"> + <i class="fas fa-exclamation-triangle icon"></i> + </div> + </div> + </template> + </XDraggable> + <p class="remain">{{ 4 - files.length }}/4</p> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import MkDriveFileThumbnail from './drive-file-thumbnail.vue' +import * as os from '@/os'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + MkDriveFileThumbnail + }, + + props: { + files: { + type: Array, + required: true + }, + detachMediaFn: { + type: Function, + required: false + } + }, + + emits: ['updated', 'detach', 'changeSensitive', 'changeName'], + + data() { + return { + menu: null as Promise<null> | null, + + }; + }, + + computed: { + _files: { + get() { + return this.files; + }, + set(value) { + this.$emit('updated', value); + } + } + }, + + methods: { + detachMedia(id) { + if (this.detachMediaFn) { + this.detachMediaFn(id); + } else { + this.$emit('detach', id); + } + }, + toggleSensitive(file) { + os.api('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive + }).then(() => { + this.$emit('changeSensitive', file, !file.isSensitive); + }); + }, + async rename(file) { + const { canceled, result } = await os.dialog({ + title: this.$ts.enterFileName, + input: { + default: file.name + }, + allowEmpty: false + }); + if (canceled) return; + os.api('drive/files/update', { + fileId: file.id, + name: result + }).then(() => { + this.$emit('changeName', file, result); + file.name = result; + }); + }, + + async describe(file) { + os.popup(import("@/components/media-caption.vue"), { + title: this.$ts.describeFile, + input: { + placeholder: this.$ts.inputNewDescription, + default: file.comment !== null ? file.comment : "", + }, + image: file + }, { + done: result => { + if (!result || result.canceled) return; + let comment = result.result; + os.api('drive/files/update', { + fileId: file.id, + comment: comment.length == 0 ? null : comment + }); + } + }, 'closed'); + }, + + showFileMenu(file, ev: MouseEvent) { + if (this.menu) return; + this.menu = os.popupMenu([{ + text: this.$ts.renameFile, + icon: 'fas fa-i-cursor', + action: () => { this.rename(file) } + }, { + text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, + icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', + action: () => { this.toggleSensitive(file) } + }, { + text: this.$ts.describeFile, + icon: 'fas fa-i-cursor', + action: () => { this.describe(file) } + }, { + text: this.$ts.attachCancel, + icon: 'fas fa-times-circle', + action: () => { this.detachMedia(file.id) } + }], ev.currentTarget || ev.target).then(() => this.menu = null); + } + } +}); +</script> + +<style lang="scss" scoped> +.skeikyzd { + padding: 8px 16px; + position: relative; + + > .files { + display: flex; + flex-wrap: wrap; + + > div { + position: relative; + width: 64px; + height: 64px; + margin-right: 4px; + border-radius: 4px; + overflow: hidden; + cursor: move; + + &:hover > .remove { + display: block; + } + + > .thumbnail { + width: 100%; + height: 100%; + z-index: 1; + color: var(--fg); + } + + > .sensitive { + display: flex; + position: absolute; + width: 64px; + height: 64px; + top: 0; + left: 0; + z-index: 2; + background: rgba(17, 17, 17, .7); + color: #fff; + + > .icon { + margin: auto; + } + } + } + } + + > .remain { + display: block; + position: absolute; + top: 8px; + right: 8px; + margin: 0; + padding: 0; + } +} +</style> diff --git a/packages/client/src/components/post-form-dialog.vue b/packages/client/src/components/post-form-dialog.vue new file mode 100644 index 0000000000..ae1cd7f01e --- /dev/null +++ b/packages/client/src/components/post-form-dialog.vue @@ -0,0 +1,19 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')" :position="'top'"> + <MkPostForm @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()" v-bind="$attrs"/> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkPostForm from '@/components/post-form.vue'; + +export default defineComponent({ + components: { + MkModal, + MkPostForm, + }, + emits: ['closed'], +}); +</script> diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue new file mode 100644 index 0000000000..ce6b7db3ee --- /dev/null +++ b/packages/client/src/components/post-form.vue @@ -0,0 +1,980 @@ +<template> +<div class="gafaadew" :class="{ modal, _popup: modal }" + v-size="{ max: [310, 500] }" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <header> + <button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button> + <div> + <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> + <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span> + <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> + <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span> + <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span> + <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> + <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> + </button> + <button class="_button preview" @click="showPreview = !showPreview" :class="{ active: showPreview }" v-tooltip="$ts.previewNoteText"><i class="fas fa-file-code"></i></button> + <button class="submit _buttonGradate" :disabled="!canPost" @click="post" data-cy-open-post-form-submit>{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> + </div> + </header> + <div class="form" :class="{ fixed }"> + <XNoteSimple class="preview" v-if="reply" :note="reply"/> + <XNoteSimple class="preview" v-if="renote" :note="renote"/> + <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> + <div v-if="visibility === 'specified'" class="to-specified"> + <span style="margin-right: 8px;">{{ $ts.recipient }}</span> + <div class="visibleUsers"> + <span v-for="u in visibleUsers" :key="u.id"> + <MkAcct :user="u"/> + <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button> + </span> + <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button> + </div> + </div> + <MkInfo warn v-if="hasNotSpecifiedMentions" class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo> + <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> + <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" data-cy-post-form-text/> + <input v-show="withHashtags" ref="hashtags" class="hashtags" v-model="hashtags" :placeholder="$ts.hashtags" list="hashtags"> + <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> + <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> + <XNotePreview class="preview" v-if="showPreview" :text="text"/> + <footer> + <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button> + <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button> + <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button> + <button class="_button" @click="withHashtags = !withHashtags" :class="{ active: withHashtags }" v-tooltip="$ts.hashtags"><i class="fas fa-hashtag"></i></button> + <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button> + <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button> + </footer> + <datalist id="hashtags"> + <option v-for="hashtag in recentHashtags" :value="hashtag" :key="hashtag"/> + </datalist> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import { length } from 'stringz'; +import { toASCII } from 'punycode/'; +import XNoteSimple from './note-simple.vue'; +import XNotePreview from './note-preview.vue'; +import * as mfm from 'mfm-js'; +import { host, url } from '@/config'; +import { erase, unique } from '@/scripts/array'; +import { extractMentions } from '@/scripts/extract-mentions'; +import * as Acct from 'misskey-js/built/acct'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { Autocomplete } from '@/scripts/autocomplete'; +import { noteVisibilities } from 'misskey-js'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { defaultStore, notePostInterruptors, postFormActions } from '@/store'; +import { isMobile } from '@/scripts/is-mobile'; +import { throttle } from 'throttle-debounce'; +import MkInfo from '@/components/ui/info.vue'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + XNoteSimple, + XNotePreview, + XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')), + XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')), + MkInfo, + }, + + inject: ['modal'], + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + channel: { + type: Object, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialVisibility: { + type: String, + required: false + }, + initialFiles: { + type: Array, + required: false + }, + initialLocalOnly: { + type: Boolean, + required: false + }, + visibleUsers: { + type: Array, + required: false, + default: () => [] + }, + initialNote: { + type: Object, + required: false + }, + share: { + type: Boolean, + required: false, + default: false + }, + fixed: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: true + }, + }, + + emits: ['posted', 'cancel', 'esc'], + + data() { + return { + posting: false, + text: '', + files: [], + poll: null, + useCw: false, + showPreview: false, + cw: null, + localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, + visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number], + autocomplete: null, + draghover: false, + quoteId: null, + hasNotSpecifiedMentions: false, + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + imeText: '', + typing: throttle(3000, () => { + if (this.channel) { + os.stream.send('typingOnChannel', { channel: this.channel.id }); + } + }), + postFormActions, + }; + }, + + computed: { + draftKey(): string { + let key = this.channel ? `channel:${this.channel.id}` : ''; + + if (this.renote) { + key += `renote:${this.renote.id}`; + } else if (this.reply) { + key += `reply:${this.reply.id}`; + } else { + key += 'note'; + } + + return key; + }, + + placeholder(): string { + if (this.renote) { + return this.$ts._postForm.quotePlaceholder; + } else if (this.reply) { + return this.$ts._postForm.replyPlaceholder; + } else if (this.channel) { + return this.$ts._postForm.channelPlaceholder; + } else { + const xs = [ + this.$ts._postForm._placeholders.a, + this.$ts._postForm._placeholders.b, + this.$ts._postForm._placeholders.c, + this.$ts._postForm._placeholders.d, + this.$ts._postForm._placeholders.e, + this.$ts._postForm._placeholders.f + ]; + return xs[Math.floor(Math.random() * xs.length)]; + } + }, + + submitText(): string { + return this.renote + ? this.$ts.quote + : this.reply + ? this.$ts.reply + : this.$ts.note; + }, + + textLength(): number { + return length((this.text + this.imeText).trim()); + }, + + canPost(): boolean { + return !this.posting && + (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) && + (this.textLength <= this.max) && + (!this.poll || this.poll.choices.length >= 2); + }, + + max(): number { + return this.$instance ? this.$instance.maxNoteTextLength : 1000; + }, + + withHashtags: defaultStore.makeGetterSetter('postFormWithHashtags'), + hashtags: defaultStore.makeGetterSetter('postFormHashtags'), + }, + + watch: { + text() { + this.checkMissingMention(); + }, + visibleUsers: { + handler() { + this.checkMissingMention(); + }, + deep: true + } + }, + + mounted() { + if (this.initialText) { + this.text = this.initialText; + } + + if (this.initialVisibility) { + this.visibility = this.initialVisibility; + } + + if (this.initialFiles) { + this.files = this.initialFiles; + } + + if (typeof this.initialLocalOnly === 'boolean') { + this.localOnly = this.initialLocalOnly; + } + + if (this.mention) { + this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; + this.text += ' '; + } + + if (this.reply && this.reply.user.host != null) { + this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; + } + + if (this.reply && this.reply.text != null) { + const ast = mfm.parse(this.reply.text); + + for (const x of extractMentions(ast)) { + const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; + + // 自分は除外 + if (this.$i.username == x.username && x.host == null) continue; + if (this.$i.username == x.username && x.host == host) continue; + + // 重複は除外 + if (this.text.indexOf(`${mention} `) != -1) continue; + + this.text += `${mention} `; + } + } + + if (this.channel) { + this.visibility = 'public'; + this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す + } + + // 公開以外へのリプライ時は元の公開範囲を引き継ぐ + if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { + this.visibility = this.reply.visibility; + if (this.reply.visibility === 'specified') { + os.api('users/show', { + userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId) + }).then(users => { + this.visibleUsers.push(...users); + }); + + if (this.reply.userId !== this.$i.id) { + os.api('users/show', { userId: this.reply.userId }).then(user => { + this.visibleUsers.push(user); + }); + } + } + } + + if (this.specified) { + this.visibility = 'specified'; + this.visibleUsers.push(this.specified); + } + + // keep cw when reply + if (this.$store.state.keepCw && this.reply && this.reply.cw) { + this.useCw = true; + this.cw = this.reply.cw; + } + + if (this.autofocus) { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + } + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + new Autocomplete(this.$refs.cw, this, { model: 'cw' }); + new Autocomplete(this.$refs.hashtags, this, { model: 'hashtags' }); + + this.$nextTick(() => { + // 書きかけの投稿を復元 + if (!this.share && !this.mention && !this.specified) { + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; + if (draft) { + this.text = draft.data.text; + this.useCw = draft.data.useCw; + this.cw = draft.data.cw; + this.visibility = draft.data.visibility; + this.localOnly = draft.data.localOnly; + this.files = (draft.data.files || []).filter(e => e); + if (draft.data.poll) { + this.poll = draft.data.poll; + } + } + } + + // 削除して編集 + if (this.initialNote) { + const init = this.initialNote; + this.text = init.text ? init.text : ''; + this.files = init.files; + this.cw = init.cw; + this.useCw = init.cw != null; + if (init.poll) { + this.poll = { + choices: init.poll.choices.map(x => x.text), + multiple: init.poll.multiple, + expiresAt: init.poll.expiresAt, + expiredAfter: init.poll.expiredAfter, + }; + } + this.visibility = init.visibility; + this.localOnly = init.localOnly; + this.quoteId = init.renote ? init.renote.id : null; + } + + this.$nextTick(() => this.watch()); + }); + }, + + methods: { + watch() { + this.$watch('text', () => this.saveDraft()); + this.$watch('useCw', () => this.saveDraft()); + this.$watch('cw', () => this.saveDraft()); + this.$watch('poll', () => this.saveDraft()); + this.$watch('files', () => this.saveDraft(), { deep: true }); + this.$watch('visibility', () => this.saveDraft()); + this.$watch('localOnly', () => this.saveDraft()); + }, + + checkMissingMention() { + if (this.visibility === 'specified') { + const ast = mfm.parse(this.text); + + for (const x of extractMentions(ast)) { + if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) { + this.hasNotSpecifiedMentions = true; + return; + } + } + this.hasNotSpecifiedMentions = false; + } + }, + + addMissingMention() { + const ast = mfm.parse(this.text); + + for (const x of extractMentions(ast)) { + if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) { + os.api('users/show', { username: x.username, host: x.host }).then(user => { + this.visibleUsers.push(user); + }); + } + } + }, + + togglePoll() { + if (this.poll) { + this.poll = null; + } else { + this.poll = { + choices: ['', ''], + multiple: false, + expiresAt: null, + expiredAfter: null, + }; + } + }, + + addTag(tag: string) { + insertTextAtCursor(this.$refs.text, ` #${tag} `); + }, + + focus() { + (this.$refs.text as any).focus(); + }, + + chooseFileFrom(ev) { + selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { + for (const file of files) { + this.files.push(file); + } + }); + }, + + detachFile(id) { + this.files = this.files.filter(x => x.id != id); + }, + + updateFiles(files) { + this.files = files; + }, + + updateFileSensitive(file, sensitive) { + this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive; + }, + + updateFileName(file, name) { + this.files[this.files.findIndex(x => x.id === file.id)].name = name; + }, + + upload(file: File, name?: string) { + os.upload(file, this.$store.state.uploadFolder, name).then(res => { + this.files.push(res); + }); + }, + + onPollUpdate(poll) { + this.poll = poll; + this.saveDraft(); + }, + + setVisibility() { + if (this.channel) { + // TODO: information dialog + return; + } + + os.popup(import('./visibility-picker.vue'), { + currentVisibility: this.visibility, + currentLocalOnly: this.localOnly, + src: this.$refs.visibilityButton + }, { + changeVisibility: visibility => { + this.visibility = visibility; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('visibility', visibility); + } + }, + changeLocalOnly: localOnly => { + this.localOnly = localOnly; + if (this.$store.state.rememberNoteVisibility) { + this.$store.set('localOnly', localOnly); + } + } + }, 'closed'); + }, + + addVisibleUser() { + os.selectUser().then(user => { + this.visibleUsers.push(user); + }); + }, + + removeVisibleUser(user) { + this.visibleUsers = erase(user, this.visibleUsers); + }, + + clear() { + this.text = ''; + this.files = []; + this.poll = null; + this.quoteId = null; + }, + + onKeydown(e: KeyboardEvent) { + if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); + if (e.which === 27) this.$emit('esc'); + this.typing(); + }, + + onCompositionUpdate(e: CompositionEvent) { + this.imeText = e.data; + this.typing(); + }, + + onCompositionEnd(e: CompositionEvent) { + this.imeText = ''; + }, + + async onPaste(e: ClipboardEvent) { + for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { + if (item.kind == 'file') { + const file = item.getAsFile(); + const lio = file.name.lastIndexOf('.'); + const ext = lio >= 0 ? file.name.slice(lio) : ''; + const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + this.upload(file, formatted); + } + } + + const paste = e.clipboardData.getData('text'); + + if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { + e.preventDefault(); + + os.dialog({ + type: 'info', + text: this.$ts.quoteQuestion, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) { + insertTextAtCursor(this.$refs.text, paste); + return; + } + + this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + }); + } + }, + + onDragover(e) { + if (!e.dataTransfer.items[0]) return; + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDragenter(e) { + this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + for (const x of Array.from(e.dataTransfer.files)) this.upload(x); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + e.preventDefault(); + } + //#endregion + }, + + saveDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftKey] = { + updatedAt: new Date(), + data: { + text: this.text, + useCw: this.useCw, + cw: this.cw, + visibility: this.visibility, + localOnly: this.localOnly, + files: this.files, + poll: this.poll + } + }; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftKey]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + + async post() { + let data = { + text: this.text == '' ? undefined : this.text, + fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + replyId: this.reply ? this.reply.id : undefined, + renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, + channelId: this.channel ? this.channel.id : undefined, + poll: this.poll, + cw: this.useCw ? this.cw || '' : undefined, + localOnly: this.localOnly, + visibility: this.visibility, + visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, + viaMobile: isMobile + }; + + if (this.withHashtags && this.hashtags && this.hashtags.trim() !== '') { + const hashtags = this.hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); + data.text = data.text ? `${data.text} ${hashtags}` : hashtags; + } + + // plugin + if (notePostInterruptors.length > 0) { + for (const interruptor of notePostInterruptors) { + data = await interruptor.handler(JSON.parse(JSON.stringify(data))); + } + } + + this.posting = true; + os.api('notes/create', data).then(() => { + this.clear(); + this.$nextTick(() => { + this.deleteDraft(); + this.$emit('posted'); + if (data.text && data.text != '') { + const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); + const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); + } + this.posting = false; + }); + }).catch(err => { + this.posting = false; + os.dialog({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); + }, + + cancel() { + this.$emit('cancel'); + }, + + insertMention() { + os.selectUser().then(user => { + insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' '); + }); + }, + + async insertEmoji(ev) { + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); + }, + + showActions(ev) { + os.popupMenu(postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: this.text + }, (key, value) => { + if (key === 'text') { this.text = value; } + }); + } + })), ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.gafaadew { + position: relative; + + &.modal { + width: 100%; + max-width: 520px; + } + + > header { + z-index: 1000; + height: 66px; + + > .cancel { + padding: 0; + font-size: 20px; + width: 64px; + line-height: 66px; + } + + > div { + position: absolute; + top: 0; + right: 0; + + > .text-count { + opacity: 0.7; + line-height: 66px; + } + + > .visibility { + height: 34px; + width: 34px; + margin: 0 0 0 8px; + + & + .localOnly { + margin-left: 0 !important; + } + } + + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; + } + + > .preview { + display: inline-block; + padding: 0; + margin: 0 8px 0 0; + font-size: 16px; + width: 34px; + height: 34px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + + > .submit { + margin: 16px 16px 16px 0; + padding: 0 12px; + line-height: 34px; + font-weight: bold; + vertical-align: bottom; + border-radius: 4px; + font-size: 0.9em; + + &:disabled { + opacity: 0.7; + } + + > i { + margin-left: 6px; + } + } + } + } + + > .form { + > .preview { + padding: 16px; + } + + > .with-quote { + margin: 0 0 8px 0; + color: var(--accent); + + > button { + padding: 4px 8px; + color: var(--accentAlpha04); + + &:hover { + color: var(--accentAlpha06); + } + + &:active { + color: var(--accentDarken30); + } + } + } + + > .to-specified { + padding: 6px 24px; + margin-bottom: 8px; + overflow: auto; + white-space: nowrap; + + > .visibleUsers { + display: inline; + top: -1px; + font-size: 14px; + + > button { + padding: 4px; + border-radius: 8px; + } + + > span { + margin-right: 14px; + padding: 8px 0 8px 8px; + border-radius: 8px; + background: var(--X4); + + > button { + padding: 4px 8px; + } + } + } + } + + > .hasNotSpecifiedMentions { + margin: 0 20px 16px 20px; + } + + > .cw, + > .hashtags, + > .text { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } + + > .cw { + z-index: 1; + padding-bottom: 8px; + border-bottom: solid 0.5px var(--divider); + } + + > .hashtags { + z-index: 1; + padding-top: 8px; + padding-bottom: 8px; + border-top: solid 0.5px var(--divider); + } + + > .text { + max-width: 100%; + min-width: 100%; + min-height: 90px; + + &.withCw { + padding-top: 8px; + } + } + + > footer { + padding: 0 16px 16px 16px; + + > button { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: 48px; + height: 48px; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + } + } + + &.max-width_500px { + > header { + height: 50px; + + > .cancel { + width: 50px; + line-height: 50px; + } + + > div { + > .text-count { + line-height: 50px; + } + + > .submit { + margin: 8px; + } + } + } + + > .form { + > .to-specified { + padding: 6px 16px; + } + + > .cw, + > .hashtags, + > .text { + padding: 0 16px; + } + + > .text { + min-height: 80px; + } + + > footer { + padding: 0 8px 8px 8px; + } + } + } + + &.max-width_310px { + > .form { + > footer { + > button { + font-size: 14px; + width: 44px; + height: 44px; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue new file mode 100644 index 0000000000..7e0ed58cbd --- /dev/null +++ b/packages/client/src/components/queue-chart.vue @@ -0,0 +1,232 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +export default defineComponent({ + props: { + domain: { + type: String, + required: true, + }, + connection: { + required: true, + }, + }, + + setup(props) { + const chartEl = ref<HTMLCanvasElement>(null); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + onMounted(() => { + const chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Process', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00E396', + backgroundColor: alpha('#00E396', 0.1), + data: [] + }, { + label: 'Active', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00BCD4', + backgroundColor: alpha('#00BCD4', 0.1), + data: [] + }, { + label: 'Waiting', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#FFB300', + backgroundColor: alpha('#FFB300', 0.1), + yAxisID: 'y2', + data: [] + }, { + label: 'Delayed', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#E53935', + borderDash: [5, 5], + fill: false, + yAxisID: 'y2', + data: [] + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 8, + }, + }, + scales: { + x: { + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10 + }, + }, + y: { + min: 0, + stack: 'queue', + stackWeight: 2, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + y2: { + min: 0, + offset: true, + stack: 'queue', + stackWeight: 1, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + }, + }, + tooltip: { + mode: 'index', + animation: { + duration: 0, + }, + }, + }, + }, + }); + + const onStats = (stats) => { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + chartInstance.update(); + }; + + const onStatsLog = (statsLog) => { + for (const stats of [...statsLog].reverse()) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + } + chartInstance.update(); + }; + + props.connection.on('stats', onStats); + props.connection.on('statsLog', onStatsLog); + + onUnmounted(() => { + props.connection.off('stats', onStats); + props.connection.off('statsLog', onStatsLog); + }); + }); + + return { + chartEl, + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/components/reaction-icon.vue b/packages/client/src/components/reaction-icon.vue new file mode 100644 index 0000000000..c0ec955e32 --- /dev/null +++ b/packages/client/src/components/reaction-icon.vue @@ -0,0 +1,25 @@ +<template> +<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + reaction: { + type: String, + required: true + }, + customEmojis: { + required: false, + default: () => [] + }, + noStyle: { + type: Boolean, + required: false, + default: false + }, + }, +}); +</script> diff --git a/packages/client/src/components/reaction-tooltip.vue b/packages/client/src/components/reaction-tooltip.vue new file mode 100644 index 0000000000..93143cbe81 --- /dev/null +++ b/packages/client/src/components/reaction-tooltip.vue @@ -0,0 +1,51 @@ +<template> +<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340"> + <div class="beeadbfb"> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> + <div class="name">{{ reaction.replace('@.', '') }}</div> + </div> +</MkTooltip> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkTooltip from './ui/tooltip.vue'; +import XReactionIcon from './reaction-icon.vue'; + +export default defineComponent({ + components: { + MkTooltip, + XReactionIcon, + }, + props: { + reaction: { + type: String, + required: true, + }, + emojis: { + type: Array, + required: true, + }, + source: { + required: true, + } + }, + emits: ['closed'], +}) +</script> + +<style lang="scss" scoped> +.beeadbfb { + text-align: center; + + > .icon { + display: block; + width: 60px; + margin: 0 auto; + } + + > .name { + font-size: 0.9em; + } +} +</style> diff --git a/packages/client/src/components/reactions-viewer.details.vue b/packages/client/src/components/reactions-viewer.details.vue new file mode 100644 index 0000000000..7c49bd1d9c --- /dev/null +++ b/packages/client/src/components/reactions-viewer.details.vue @@ -0,0 +1,91 @@ +<template> +<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340"> + <div class="bqxuuuey"> + <div class="reaction"> + <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> + <div class="name">{{ reaction.replace('@.', '') }}</div> + </div> + <div class="users"> + <template v-if="users.length <= 10"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + </template> + <template v-if="10 < users.length"> + <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> + <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> + <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> + </b> + <span slot="omitted">+{{ count - 10 }}</span> + </template> + </div> + </div> +</MkTooltip> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkTooltip from './ui/tooltip.vue'; +import XReactionIcon from './reaction-icon.vue'; + +export default defineComponent({ + components: { + MkTooltip, + XReactionIcon + }, + props: { + reaction: { + type: String, + required: true, + }, + users: { + type: Array, + required: true, + }, + count: { + type: Number, + required: true, + }, + emojis: { + type: Array, + required: true, + }, + source: { + required: true, + } + }, + emits: ['closed'], +}) +</script> + +<style lang="scss" scoped> +.bqxuuuey { + display: flex; + + > .reaction { + flex: 1; + max-width: 100px; + text-align: center; + + > .icon { + display: block; + width: 60px; + margin: 0 auto; + } + + > .name { + font-size: 0.9em; + } + } + + > .users { + flex: 1; + min-width: 0; + font-size: 0.9em; + border-left: solid 0.5px var(--divider); + padding-left: 10px; + margin-left: 10px; + } +} +</style> diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue new file mode 100644 index 0000000000..47a3bb9720 --- /dev/null +++ b/packages/client/src/components/reactions-viewer.reaction.vue @@ -0,0 +1,183 @@ +<template> +<button + class="hkzvhatu _button" + :class="{ reacted: note.myReaction == reaction, canToggle }" + @click="toggleReaction(reaction)" + v-if="count > 0" + @touchstart.passive="onMouseover" + @mouseover="onMouseover" + @mouseleave="onMouseleave" + @touchend="onMouseleave" + ref="reaction" + v-particle="canToggle" +> + <XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/> + <span>{{ count }}</span> +</button> +</template> + +<script lang="ts"> +import { defineComponent, ref } from 'vue'; +import XDetails from '@/components/reactions-viewer.details.vue'; +import XReactionIcon from '@/components/reaction-icon.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XReactionIcon + }, + props: { + reaction: { + type: String, + required: true, + }, + count: { + type: Number, + required: true, + }, + isInitial: { + type: Boolean, + required: true, + }, + note: { + type: Object, + required: true, + }, + }, + data() { + return { + close: null, + detailsTimeoutId: null, + isHovering: false + }; + }, + computed: { + canToggle(): boolean { + return !this.reaction.match(/@\w/) && this.$i; + }, + }, + watch: { + count(newCount, oldCount) { + if (oldCount < newCount) this.anime(); + if (this.close != null) this.openDetails(); + }, + }, + mounted() { + if (!this.isInitial) this.anime(); + }, + methods: { + toggleReaction() { + if (!this.canToggle) return; + + const oldReaction = this.note.myReaction; + if (oldReaction) { + os.api('notes/reactions/delete', { + noteId: this.note.id + }).then(() => { + if (oldReaction !== this.reaction) { + os.api('notes/reactions/create', { + noteId: this.note.id, + reaction: this.reaction + }); + } + }); + } else { + os.api('notes/reactions/create', { + noteId: this.note.id, + reaction: this.reaction + }); + } + }, + onMouseover() { + if (this.isHovering) return; + this.isHovering = true; + this.detailsTimeoutId = setTimeout(this.openDetails, 300); + }, + onMouseleave() { + if (!this.isHovering) return; + this.isHovering = false; + clearTimeout(this.detailsTimeoutId); + this.closeDetails(); + }, + openDetails() { + os.api('notes/reactions', { + noteId: this.note.id, + type: this.reaction, + limit: 11 + }).then((reactions: any[]) => { + const users = reactions + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + .map(x => x.user); + + this.closeDetails(); + if (!this.isHovering) return; + + const showing = ref(true); + os.popup(XDetails, { + showing, + reaction: this.reaction, + emojis: this.note.emojis, + users, + count: this.count, + source: this.$refs.reaction + }, {}, 'closed'); + + this.close = () => { + showing.value = false; + }; + }); + }, + closeDetails() { + if (this.close != null) { + this.close(); + this.close = null; + } + }, + anime() { + if (document.hidden) return; + + // TODO + }, + } +}); +</script> + +<style lang="scss" scoped> +.hkzvhatu { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + border-radius: 4px; + + &.canToggle { + background: rgba(0, 0, 0, 0.05); + + &:hover { + background: rgba(0, 0, 0, 0.1); + } + } + + &:not(.canToggle) { + cursor: default; + } + + &.reacted { + background: var(--accent); + + &:hover { + background: var(--accent); + } + + > span { + color: var(--fgOnAccent); + } + } + + > span { + font-size: 0.9em; + line-height: 32px; + margin: 0 0 0 4px; + } +} +</style> diff --git a/packages/client/src/components/reactions-viewer.vue b/packages/client/src/components/reactions-viewer.vue new file mode 100644 index 0000000000..94a0318734 --- /dev/null +++ b/packages/client/src/components/reactions-viewer.vue @@ -0,0 +1,48 @@ +<template> +<div class="tdflqwzn" :class="{ isMe }"> + <XReaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XReaction from './reactions-viewer.reaction.vue'; + +export default defineComponent({ + components: { + XReaction + }, + props: { + note: { + type: Object, + required: true + }, + }, + data() { + return { + initialReactions: new Set(Object.keys(this.note.reactions)) + }; + }, + computed: { + isMe(): boolean { + return this.$i && this.$i.id === this.note.userId; + }, + }, +}); +</script> + +<style lang="scss" scoped> +.tdflqwzn { + margin: 4px -2px 0 -2px; + + &:empty { + display: none; + } + + &.isMe { + > span { + cursor: default !important; + } + } +} +</style> diff --git a/packages/client/src/components/remote-caution.vue b/packages/client/src/components/remote-caution.vue new file mode 100644 index 0000000000..c496ea8f48 --- /dev/null +++ b/packages/client/src/components/remote-caution.vue @@ -0,0 +1,35 @@ +<template> +<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + href: { + type: String, + required: true + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.jmgmzlwq { + font-size: 0.8em; + padding: 16px; + background: var(--infoWarnBg); + color: var(--infoWarnFg); + + > a { + margin-left: 4px; + color: var(--accent); + } +} +</style> diff --git a/packages/client/src/components/sample.vue b/packages/client/src/components/sample.vue new file mode 100644 index 0000000000..ba6c682c44 --- /dev/null +++ b/packages/client/src/components/sample.vue @@ -0,0 +1,116 @@ +<template> +<div class="_card"> + <div class="_content"> + <MkInput v-model="text"> + <template #label>Text</template> + </MkInput> + <MkSwitch v-model="flag"> + <span>Switch is now {{ flag ? 'on' : 'off' }}</span> + </MkSwitch> + <div style="margin: 32px 0;"> + <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> + <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> + <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> + </div> + <MkButton inline>This is</MkButton> + <MkButton inline primary>the button</MkButton> + </div> + <div class="_content" style="pointer-events: none;"> + <Mfm :text="mfm"/> + </div> + <div class="_content"> + <MkButton inline primary @click="openMenu">Open menu</MkButton> + <MkButton inline primary @click="openDialog">Open dialog</MkButton> + <MkButton inline primary @click="openForm">Open form</MkButton> + <MkButton inline primary @click="openDrive">Open drive</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkRadio from '@/components/form/radio.vue'; +import * as os from '@/os'; +import * as config from '@/config'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + MkTextarea, + MkRadio, + }, + + data() { + return { + text: '', + flag: true, + radio: 'misskey', + mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.` + } + }, + + methods: { + async openDialog() { + os.dialog({ + type: 'warning', + title: 'Oh my Aichan', + text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }); + }, + + async openForm() { + os.form('Example form', { + foo: { + type: 'boolean', + default: true, + label: 'This is a boolean property' + }, + bar: { + type: 'number', + default: 300, + label: 'This is a number property' + }, + baz: { + type: 'string', + default: 'Misskey makes you happy.', + label: 'This is a string property' + }, + }); + }, + + async openDrive() { + os.selectDriveFile(); + }, + + async selectUser() { + os.selectUser(); + }, + + async openMenu(ev) { + os.popupMenu([{ + type: 'label', + text: 'Fruits' + }, { + text: 'Create some apples', + action: () => {}, + }, { + text: 'Read some oranges', + action: () => {}, + }, { + text: 'Update some melons', + action: () => {}, + }, null, { + text: 'Delete some bananas', + danger: true, + action: () => {}, + }], ev.currentTarget || ev.target); + }, + } +}); +</script> diff --git a/packages/client/src/components/signin-dialog.vue b/packages/client/src/components/signin-dialog.vue new file mode 100644 index 0000000000..2edd10f539 --- /dev/null +++ b/packages/client/src/components/signin-dialog.vue @@ -0,0 +1,42 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :height="400" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.login }}</template> + + <MkSignin :auto-set="autoSet" @login="onLogin"/> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkSignin from './signin.vue'; + +export default defineComponent({ + components: { + MkSignin, + XModalWindow, + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['done', 'closed'], + + methods: { + onLogin(res) { + this.$emit('done', res); + this.$refs.dialog.close(); + } + } +}); +</script> diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue new file mode 100644 index 0000000000..68bbd5368e --- /dev/null +++ b/packages/client/src/components/signin.vue @@ -0,0 +1,240 @@ +<template> +<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> + <div class="auth _section _formRoot"> + <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> + <div class="normal-signin" v-if="!totpLogin"> + <MkInput class="_formBlock" v-model="username" :placeholder="$ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:modelValue="onUsernameChange" data-cy-signin-username> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput class="_formBlock" v-model="password" :placeholder="$ts.password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required data-cy-signin-password> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption><button class="_textButton" @click="resetPassword" type="button">{{ $ts.forgotPassword }}</button></template> + </MkInput> + <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> + </div> + <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> + <div v-if="user && user.securityKeys" class="twofa-group tap-group"> + <p>{{ $ts.tapSecurityKey }}</p> + <MkButton @click="queryKey" v-if="!queryingKey"> + {{ $ts.retry }} + </MkButton> + </div> + <div class="or-hr" v-if="user && user.securityKeys"> + <p class="or-msg">{{ $ts.or }}</p> + </div> + <div class="twofa-group totp-group"> + <p style="margin-bottom:0;">{{ $ts.twoStepAuthentication }}</p> + <MkInput v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="fas fa-lock"></i></template> + </MkInput> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <template #label>{{ $ts.token }}</template> + <template #prefix><i class="fas fa-gavel"></i></template> + </MkInput> + <MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> + </div> + </div> + </div> + <div class="social _section"> + <a class="_borderButton _gap" v-if="meta && meta.enableTwitterIntegration" :href="`${apiUrl}/signin/twitter`"><i class="fab fa-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a> + <a class="_borderButton _gap" v-if="meta && meta.enableGithubIntegration" :href="`${apiUrl}/signin/github`"><i class="fab fa-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a> + <a class="_borderButton _gap" v-if="meta && meta.enableDiscordIntegration" :href="`${apiUrl}/signin/discord`"><i class="fab fa-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a> + </div> +</form> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import { apiUrl, host } from '@/config'; +import { byteify, hexify } from '@/scripts/2fa'; +import * as os from '@/os'; +import { login } from '@/account'; +import { showSuspendedDialog } from '../scripts/show-suspended-dialog'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + }, + + props: { + withAvatar: { + type: Boolean, + required: false, + default: true + }, + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['login'], + + data() { + return { + signing: false, + user: null, + username: '', + password: '', + token: '', + apiUrl, + host: toUnicode(host), + totpLogin: false, + credential: null, + challengeData: null, + queryingKey: false, + }; + }, + + computed: { + meta() { + return this.$instance; + }, + }, + + methods: { + onUsernameChange() { + os.api('users/show', { + username: this.username + }).then(user => { + this.user = user; + }, () => { + this.user = null; + }); + }, + + onLogin(res) { + if (this.autoSet) { + return login(res.i); + } else { + return; + } + }, + + queryKey() { + this.queryingKey = true; + return navigator.credentials.get({ + publicKey: { + challenge: byteify(this.challengeData.challenge, 'base64'), + allowCredentials: this.challengeData.securityKeys.map(key => ({ + id: byteify(key.id, 'hex'), + type: 'public-key', + transports: ['usb', 'nfc', 'ble', 'internal'] + })), + timeout: 60 * 1000 + } + }).catch(() => { + this.queryingKey = false; + return Promise.reject(null); + }).then(credential => { + this.queryingKey = false; + this.signing = true; + return os.api('signin', { + username: this.username, + password: this.password, + signature: hexify(credential.response.signature), + authenticatorData: hexify(credential.response.authenticatorData), + clientDataJSON: hexify(credential.response.clientDataJSON), + credentialId: credential.id, + challengeId: this.challengeData.challengeId + }); + }).then(res => { + this.$emit('login', res); + return this.onLogin(res); + }).catch(err => { + if (err === null) return; + os.dialog({ + type: 'error', + text: this.$ts.signinFailed + }); + this.signing = false; + }); + }, + + onSubmit() { + this.signing = true; + if (!this.totpLogin && this.user && this.user.twoFactorEnabled) { + if (window.PublicKeyCredential && this.user.securityKeys) { + os.api('signin', { + username: this.username, + password: this.password + }).then(res => { + this.totpLogin = true; + this.signing = false; + this.challengeData = res; + return this.queryKey(); + }).catch(this.loginFailed); + } else { + this.totpLogin = true; + this.signing = false; + } + } else { + os.api('signin', { + username: this.username, + password: this.password, + token: this.user && this.user.twoFactorEnabled ? this.token : undefined + }).then(res => { + this.$emit('login', res); + this.onLogin(res); + }).catch(this.loginFailed); + } + }, + + loginFailed(err) { + switch (err.id) { + case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { + os.dialog({ + type: 'error', + title: this.$ts.loginFailed, + text: this.$ts.noSuchUser + }); + break; + } + case 'e03a5f46-d309-4865-9b69-56282d94e1eb': { + showSuspendedDialog(); + break; + } + default: { + os.dialog({ + type: 'error', + title: this.$ts.loginFailed, + text: JSON.stringify(err) + }); + } + } + + this.challengeData = null; + this.totpLogin = false; + this.signing = false; + }, + + resetPassword() { + os.popup(import('@/components/forgot-password.vue'), {}, { + }, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.eppvobhk { + > .auth { + > .avatar { + margin: 0 auto 0 auto; + width: 64px; + height: 64px; + background: #ddd; + background-position: center; + background-size: cover; + border-radius: 100%; + } + } +} +</style> diff --git a/packages/client/src/components/signup-dialog.vue b/packages/client/src/components/signup-dialog.vue new file mode 100644 index 0000000000..30fe3bf7d3 --- /dev/null +++ b/packages/client/src/components/signup-dialog.vue @@ -0,0 +1,50 @@ +<template> +<XModalWindow ref="dialog" + :width="366" + :height="500" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.signup }}</template> + + <div class="_monolithic_"> + <div class="_section"> + <XSignup :auto-set="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import XSignup from './signup.vue'; + +export default defineComponent({ + components: { + XSignup, + XModalWindow, + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['done', 'closed'], + + methods: { + onSignup(res) { + this.$emit('done', res); + this.$refs.dialog.close(); + }, + + onSignupEmailPending() { + this.$refs.dialog.close(); + } + } +}); +</script> diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue new file mode 100644 index 0000000000..621f30486f --- /dev/null +++ b/packages/client/src/components/signup.vue @@ -0,0 +1,268 @@ +<template> +<form class="qlvuhzng _formRoot" @submit.prevent="onSubmit" :autocomplete="Math.random()"> + <template v-if="meta"> + <MkInput class="_formBlock" v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> + <template #label>{{ $ts.invitationCode }}</template> + <template #prefix><i class="fas fa-key"></i></template> + </MkInput> + <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeUsername" data-cy-signup-username> + <template #label>{{ $ts.username }} <div class="_button _help" v-tooltip:dialog="$ts.usernameInfo"><i class="far fa-question-circle"></i></div></template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + <template #caption> + <span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> + <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> + <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> + <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> + <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span> + <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span> + <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span> + </template> + </MkInput> + <MkInput v-if="meta.emailRequiredForSignup" class="_formBlock" v-model="email" :debounce="true" type="email" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeEmail" data-cy-signup-email> + <template #label>{{ $ts.emailAddress }} <div class="_button _help" v-tooltip:dialog="$ts._signup.emailAddressInfo"><i class="far fa-question-circle"></i></div></template> + <template #prefix><i class="fas fa-envelope"></i></template> + <template #caption> + <span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> + <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> + <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span> + <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span> + <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span> + <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span> + <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</span> + <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> + <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> + </template> + </MkInput> + <MkInput class="_formBlock" v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption> + <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span> + <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span> + <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span> + </template> + </MkInput> + <MkInput class="_formBlock" v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePasswordRetype" data-cy-signup-password-retype> + <template #label>{{ $ts.password }} ({{ $ts.retype }})</template> + <template #prefix><i class="fas fa-lock"></i></template> + <template #caption> + <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span> + <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span> + </template> + </MkInput> + <label v-if="meta.tosUrl" class="_formBlock tou"> + <input type="checkbox" v-model="ToSAgreement"> + <I18n :src="$ts.agreeTo"> + <template #0> + <a :href="meta.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a> + </template> + </I18n> + </label> + <captcha v-if="meta.enableHcaptcha" class="_formBlock captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> + <captcha v-if="meta.enableRecaptcha" class="_formBlock captcha" provider="recaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> + <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton> + </template> +</form> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +const getPasswordStrength = require('syuilo-password-strength'); +import { toUnicode } from 'punycode/'; +import { host, url } from '@/config'; +import MkButton from './ui/button.vue'; +import MkInput from './form/input.vue'; +import MkSwitch from './form/switch.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + captcha: defineAsyncComponent(() => import('./captcha.vue')), + }, + + props: { + autoSet: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['signup'], + + data() { + return { + host: toUnicode(host), + username: '', + password: '', + retypedPassword: '', + invitationCode: '', + email: '', + url, + usernameState: null, + emailState: null, + passwordStrength: '', + passwordRetypeState: null, + submitting: false, + ToSAgreement: false, + hCaptchaResponse: null, + reCaptchaResponse: null, + } + }, + + computed: { + meta() { + return this.$instance; + }, + + shouldDisableSubmitting(): boolean { + return this.submitting || + this.meta.tosUrl && !this.ToSAgreement || + this.meta.enableHcaptcha && !this.hCaptchaResponse || + this.meta.enableRecaptcha && !this.reCaptchaResponse || + this.passwordRetypeState == 'not-match'; + }, + + shouldShowProfileUrl(): boolean { + return (this.username != '' && + this.usernameState != 'invalid-format' && + this.usernameState != 'min-range' && + this.usernameState != 'max-range'); + } + }, + + methods: { + onChangeUsername() { + if (this.username == '') { + this.usernameState = null; + return; + } + + const err = + !this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : + this.username.length < 1 ? 'min-range' : + this.username.length > 20 ? 'max-range' : + null; + + if (err) { + this.usernameState = err; + return; + } + + this.usernameState = 'wait'; + + os.api('username/available', { + username: this.username + }).then(result => { + this.usernameState = result.available ? 'ok' : 'unavailable'; + }).catch(err => { + this.usernameState = 'error'; + }); + }, + + onChangeEmail() { + if (this.email == '') { + this.emailState = null; + return; + } + + this.emailState = 'wait'; + + os.api('email-address/available', { + emailAddress: this.email + }).then(result => { + this.emailState = result.available ? 'ok' : + result.reason === 'used' ? 'unavailable:used' : + result.reason === 'format' ? 'unavailable:format' : + result.reason === 'disposable' ? 'unavailable:disposable' : + result.reason === 'mx' ? 'unavailable:mx' : + result.reason === 'smtp' ? 'unavailable:smtp' : + 'unavailable'; + }).catch(err => { + this.emailState = 'error'; + }); + }, + + onChangePassword() { + if (this.password == '') { + this.passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(this.password); + this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; + }, + + onChangePasswordRetype() { + if (this.retypedPassword == '') { + this.passwordRetypeState = null; + return; + } + + this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; + }, + + onSubmit() { + if (this.submitting) return; + this.submitting = true; + + os.api('signup', { + username: this.username, + password: this.password, + emailAddress: this.email, + invitationCode: this.invitationCode, + 'hcaptcha-response': this.hCaptchaResponse, + 'g-recaptcha-response': this.reCaptchaResponse, + }).then(() => { + if (this.meta.emailRequiredForSignup) { + os.dialog({ + type: 'success', + title: this.$ts._signup.almostThere, + text: this.$t('_signup.emailSent', { email: this.email }), + }); + this.$emit('signupEmailPending'); + } else { + os.api('signin', { + username: this.username, + password: this.password + }).then(res => { + this.$emit('signup', res); + + if (this.autoSet) { + login(res.i); + } + }); + } + }).catch(() => { + this.submitting = false; + this.$refs.hcaptcha?.reset?.(); + this.$refs.recaptcha?.reset?.(); + + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.qlvuhzng { + .captcha { + margin: 16px 0; + } + + > .tou { + display: block; + margin: 16px 0; + cursor: pointer; + } +} +</style> diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue new file mode 100644 index 0000000000..3aaf03995d --- /dev/null +++ b/packages/client/src/components/sparkle.vue @@ -0,0 +1,179 @@ +<template> +<span class="mk-sparkle"> + <span ref="content"> + <slot></slot> + </span> + <canvas ref="canvas"></canvas> +</span> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +const sprite = new Image(); +sprite.src = '/client-assets/sparkle-spritesheet.png'; + +export default defineComponent({ + props: { + count: { + type: Number, + required: true, + }, + speed: { + type: Number, + required: true, + }, + }, + data() { + return { + sprites: [0,6,13,20], + particles: [], + anim: null, + ctx: null, + }; + }, + methods: { + createSparkles(w, h, count) { + var holder = []; + + for (var i = 0; i < count; i++) { + + const color = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6); + + holder[i] = { + position: { + x: Math.floor(Math.random() * w), + y: Math.floor(Math.random() * h) + }, + style: this.sprites[ Math.floor(Math.random() * 4) ], + delta: { + x: Math.floor(Math.random() * 1000) - 500, + y: Math.floor(Math.random() * 1000) - 500 + }, + color: color, + opacity: Math.random(), + }; + + } + + return holder; + }, + draw(time) { + this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height); + this.ctx.beginPath(); + + const particleSize = Math.floor(this.fontSize / 2); + this.particles.forEach((particle) => { + var modulus = Math.floor(Math.random()*7); + + if (Math.floor(time) % modulus === 0) { + particle.style = this.sprites[ Math.floor(Math.random()*4) ]; + } + + this.ctx.save(); + this.ctx.globalAlpha = particle.opacity; + this.ctx.drawImage(sprite, particle.style, 0, 7, 7, particle.position.x, particle.position.y, particleSize, particleSize); + + this.ctx.globalCompositeOperation = "source-atop"; + this.ctx.globalAlpha = 0.5; + this.ctx.fillStyle = particle.color; + this.ctx.fillRect(particle.position.x, particle.position.y, particleSize, particleSize); + + this.ctx.restore(); + }); + this.ctx.stroke(); + }, + tick() { + this.anim = window.requestAnimationFrame((time) => { + if (!this.$refs.canvas) { + return; + } + this.particles.forEach((particle) => { + if (!particle) { + return; + } + var randX = Math.random() > Math.random() * 2; + var randY = Math.random() > Math.random() * 3; + + if (randX) { + particle.position.x += (particle.delta.x * this.speed) / 1500; + } + + if (!randY) { + particle.position.y -= (particle.delta.y * this.speed) / 800; + } + + if( particle.position.x > this.$refs.canvas.width ) { + particle.position.x = -7; + } else if (particle.position.x < -7) { + particle.position.x = this.$refs.canvas.width; + } + + if (particle.position.y > this.$refs.canvas.height) { + particle.position.y = -7; + particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width); + } else if (particle.position.y < -7) { + particle.position.y = this.$refs.canvas.height; + particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width); + } + + particle.opacity -= 0.005; + + if (particle.opacity <= 0) { + particle.opacity = 1; + } + }); + + this.draw(time); + + this.tick(); + }); + }, + resize() { + if (this.$refs.content) { + const contentRect = this.$refs.content.getBoundingClientRect(); + this.fontSize = parseFloat(getComputedStyle(this.$refs.content).fontSize); + const padding = this.fontSize * 0.2; + + this.$refs.canvas.width = parseInt(contentRect.width + padding); + this.$refs.canvas.height = parseInt(contentRect.height + padding); + + this.particles = this.createSparkles(this.$refs.canvas.width, this.$refs.canvas.height, this.count); + } + }, + }, + mounted() { + this.ctx = this.$refs.canvas.getContext('2d'); + + new ResizeObserver(this.resize).observe(this.$refs.content); + + this.resize(); + this.tick(); + }, + updated() { + this.resize(); + }, + destroyed() { + window.cancelAnimationFrame(this.anim); + }, +}); +</script> + +<style lang="scss" scoped> +.mk-sparkle { + position: relative; + display: inline-block; + + > span { + display: inline-block; + } + + > canvas { + position: absolute; + top: -0.1em; + left: -0.1em; + pointer-events: none; + } +} +</style> diff --git a/packages/client/src/components/sub-note-content.vue b/packages/client/src/components/sub-note-content.vue new file mode 100644 index 0000000000..3f03f021cd --- /dev/null +++ b/packages/client/src/components/sub-note-content.vue @@ -0,0 +1,62 @@ +<template> +<div class="wrmlmaau"> + <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> + <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> + <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <XMediaList :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ $ts.poll }}</summary> + <XPoll :note="note"/> + </details> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPoll from './poll.vue'; +import XMediaList from './media-list.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XPoll, + XMediaList, + }, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } +} +</style> diff --git a/packages/client/src/components/tab.vue b/packages/client/src/components/tab.vue new file mode 100644 index 0000000000..c629727358 --- /dev/null +++ b/packages/client/src/components/tab.vue @@ -0,0 +1,73 @@ +<script lang="ts"> +import { defineComponent, h, resolveDirective, withDirectives } from 'vue'; + +export default defineComponent({ + props: { + modelValue: { + required: true, + }, + }, + render() { + const options = this.$slots.default(); + + return withDirectives(h('div', { + class: 'pxhvhrfw', + }, options.map(option => withDirectives(h('button', { + class: ['_button', { active: this.modelValue === option.props.value }], + key: option.key, + disabled: this.modelValue === option.props.value, + onClick: () => { + this.$emit('update:modelValue', option.props.value); + } + }, option.children), [ + [resolveDirective('click-anime')] + ]))), [ + [resolveDirective('size'), { max: [500] }] + ]); + } +}); +</script> + +<style lang="scss"> +.pxhvhrfw { + display: flex; + font-size: 90%; + + > button { + flex: 1; + padding: 10px 8px; + border-radius: var(--radius); + + &:disabled { + opacity: 1 !important; + cursor: default; + } + + &.active { + color: var(--accent); + background: var(--accentedBg); + } + + &:not(.active):hover { + color: var(--fgHighlighted); + background: var(--panelHighlight); + } + + &:not(:first-child) { + margin-left: 8px; + } + + > .icon { + margin-right: 6px; + } + } + + &.max-width_500px { + font-size: 80%; + + > button { + padding: 11px 8px; + } + } +} +</style> diff --git a/packages/client/src/components/taskmanager.api-window.vue b/packages/client/src/components/taskmanager.api-window.vue new file mode 100644 index 0000000000..6ec4da3a59 --- /dev/null +++ b/packages/client/src/components/taskmanager.api-window.vue @@ -0,0 +1,72 @@ +<template> +<XWindow ref="window" + :initial-width="370" + :initial-height="450" + :can-resize="true" + @close="$refs.window.close()" + @closed="$emit('closed')" +> + <template #header>Req Viewer</template> + + <div class="rlkneywz"> + <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> + <option value="req">Request</option> + <option value="res">Response</option> + </MkTab> + + <code v-if="tab === 'req'" class="_monospace">{{ reqStr }}</code> + <code v-if="tab === 'res'" class="_monospace">{{ resStr }}</code> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import XWindow from '@/components/ui/window.vue'; +import MkTab from '@/components/tab.vue'; + +export default defineComponent({ + components: { + XWindow, + MkTab, + }, + + props: { + req: { + required: true, + } + }, + + emits: ['closed'], + + data() { + return { + tab: 'req', + reqStr: JSON5.stringify(this.req.req, null, '\t'), + resStr: JSON5.stringify(this.req.res, null, '\t'), + } + }, + + methods: { + } +}); +</script> + +<style lang="scss" scoped> +.rlkneywz { + display: flex; + flex-direction: column; + height: 100%; + + > code { + display: block; + flex: 1; + padding: 8px; + overflow: auto; + font-size: 0.9em; + tab-size: 2; + white-space: pre; + } +} +</style> diff --git a/packages/client/src/components/taskmanager.vue b/packages/client/src/components/taskmanager.vue new file mode 100644 index 0000000000..6efbf286e6 --- /dev/null +++ b/packages/client/src/components/taskmanager.vue @@ -0,0 +1,233 @@ +<template> +<XWindow ref="window" :initial-width="650" :initial-height="420" :can-resize="true" @closed="$emit('closed')"> + <template #header> + <i class="fas fa-terminal" style="margin-right: 0.5em;"></i>Task Manager + </template> + <div class="qljqmnzj _monospace"> + <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> + <option value="windows">Windows</option> + <option value="stream">Stream</option> + <option value="streamPool">Stream (Pool)</option> + <option value="api">API</option> + </MkTab> + + <div class="content"> + <div v-if="tab === 'windows'" class="windows" v-follow> + <div class="header"> + <div>#ID</div> + <div>Component</div> + <div>Action</div> + </div> + <div v-for="p in popups"> + <div>#{{ p.id }}</div> + <div>{{ p.component.name ? p.component.name : '<anonymous>' }}</div> + <div><button class="_textButton" @click="killPopup(p)">Kill</button></div> + </div> + </div> + <div v-if="tab === 'stream'" class="stream" v-follow> + <div class="header"> + <div>#ID</div> + <div>Ch</div> + <div>Handle</div> + <div>In</div> + <div>Out</div> + </div> + <div v-for="c in connections"> + <div>#{{ c.id }}</div> + <div>{{ c.channel }}</div> + <div v-if="c.users !== null">(shared)<span v-if="c.name">{{ ' ' + c.name }}</span></div> + <div v-else>{{ c.name ? c.name : '<anonymous>' }}</div> + <div>{{ c.in }}</div> + <div>{{ c.out }}</div> + </div> + </div> + <div v-if="tab === 'streamPool'" class="streamPool" v-follow> + <div class="header"> + <div>#ID</div> + <div>Ch</div> + <div>Users</div> + </div> + <div v-for="p in pools"> + <div>#{{ p.id }}</div> + <div>{{ p.channel }}</div> + <div>{{ p.users }}</div> + </div> + </div> + <div v-if="tab === 'api'" class="api" v-follow> + <div class="header"> + <div>#ID</div> + <div>Endpoint</div> + <div>State</div> + </div> + <div v-for="req in apiRequests" @click="showReq(req)"> + <div>#{{ req.id }}</div> + <div>{{ req.endpoint }}</div> + <div class="state" :class="req.state">{{ req.state }}</div> + </div> + </div> + </div> + + <footer> + <div><span class="label">Windows</span>{{ popups.length }}</div> + <div><span class="label">Stream</span>{{ connections.length }}</div> + <div><span class="label">Stream (Pool)</span>{{ pools.length }}</div> + </footer> + </div> +</XWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw, onBeforeUnmount, ref, shallowRef } from 'vue'; +import XWindow from '@/components/ui/window.vue'; +import MkTab from '@/components/tab.vue'; +import MkButton from '@/components/ui/button.vue'; +import follow from '@/directives/follow-append'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XWindow, + MkTab, + MkButton, + }, + + directives: { + follow + }, + + props: { + }, + + emits: ['closed'], + + setup() { + const connections = shallowRef([]); + const pools = shallowRef([]); + const refreshStreamInfo = () => { + console.log(os.stream.sharedConnectionPools, os.stream.sharedConnections, os.stream.nonSharedConnections); + const conn = os.stream.sharedConnections.map(c => ({ + id: c.id, name: c.name, channel: c.channel, users: c.pool.users, in: c.inCount, out: c.outCount, + })).concat(os.stream.nonSharedConnections.map(c => ({ + id: c.id, name: c.name, channel: c.channel, users: null, in: c.inCount, out: c.outCount, + }))); + conn.sort((a, b) => (a.id > b.id) ? 1 : -1); + connections.value = conn; + pools.value = os.stream.sharedConnectionPools; + }; + const interval = setInterval(refreshStreamInfo, 1000); + onBeforeUnmount(() => { + clearInterval(interval); + }); + + const killPopup = p => { + os.popups.value = os.popups.value.filter(x => x !== p); + }; + + const showReq = req => { + os.popup(import('./taskmanager.api-window.vue'), { + req: req + }, { + }, 'closed'); + }; + + return { + tab: ref('stream'), + popups: os.popups, + apiRequests: os.apiRequests, + connections, + pools, + killPopup, + showReq, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.qljqmnzj { + display: flex; + flex-direction: column; + height: 100%; + + > .content { + flex: 1; + overflow: auto; + + > div { + display: table; + width: 100%; + padding: 16px; + box-sizing: border-box; + + > div { + display: table-row; + + &:nth-child(even) { + //background: rgba(0, 0, 0, 0.1); + } + + &.header { + opacity: 0.7; + } + + > div { + display: table-cell; + white-space: nowrap; + + &:not(:last-child) { + padding-right: 8px; + } + } + } + + &.api { + > div { + &:not(.header) { + cursor: pointer; + + &:hover { + color: var(--accent); + } + } + + > .state { + &.pending { + color: var(--warn); + } + + &.success { + color: var(--success); + } + + &.failed { + color: var(--error); + } + } + } + } + } + } + + > footer { + display: flex; + width: 100%; + padding: 8px 16px; + box-sizing: border-box; + border-top: solid 0.5px var(--divider); + font-size: 0.9em; + + > div { + flex: 1; + + > .label { + opacity: 0.7; + margin-right: 0.5em; + + &:after { + content: ":"; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/timeline.vue new file mode 100644 index 0000000000..fa7f4e7f4d --- /dev/null +++ b/packages/client/src/components/timeline.vue @@ -0,0 +1,183 @@ +<template> +<XNotes :no-gap="!$store.state.showGapBetweenNotesInTimeline" ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import XNotes from './notes.vue'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; + +export default defineComponent({ + components: { + XNotes + }, + + provide() { + return { + inChannel: this.src === 'channel' + }; + }, + + props: { + src: { + type: String, + required: true + }, + list: { + type: String, + required: false + }, + antenna: { + type: String, + required: false + }, + channel: { + type: String, + required: false + }, + sound: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['note', 'queue', 'before', 'after'], + + data() { + return { + connection: null, + connection2: null, + pagination: null, + baseQuery: { + includeMyRenotes: this.$store.state.showMyRenotes, + includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.showLocalRenotes + }, + query: {}, + date: null + }; + }, + + created() { + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + if (this.sound) { + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + } + }; + + const onUserAdded = () => { + (this.$refs.tl as any).reload(); + }; + + const onUserRemoved = () => { + (this.$refs.tl as any).reload(); + }; + + const onChangeFollowing = () => { + if (!this.$refs.tl.backed) { + this.$refs.tl.reload(); + } + }; + + let endpoint; + + if (this.src == 'antenna') { + endpoint = 'antennas/notes'; + this.query = { + antennaId: this.antenna + }; + this.connection = markRaw(os.stream.useChannel('antenna', { + antennaId: this.antenna + })); + this.connection.on('note', prepend); + } else if (this.src == 'home') { + endpoint = 'notes/timeline'; + this.connection = markRaw(os.stream.useChannel('homeTimeline')); + this.connection.on('note', prepend); + + this.connection2 = markRaw(os.stream.useChannel('main')); + this.connection2.on('follow', onChangeFollowing); + this.connection2.on('unfollow', onChangeFollowing); + } else if (this.src == 'local') { + endpoint = 'notes/local-timeline'; + this.connection = markRaw(os.stream.useChannel('localTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'social') { + endpoint = 'notes/hybrid-timeline'; + this.connection = markRaw(os.stream.useChannel('hybridTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'global') { + endpoint = 'notes/global-timeline'; + this.connection = markRaw(os.stream.useChannel('globalTimeline')); + this.connection.on('note', prepend); + } else if (this.src == 'mentions') { + endpoint = 'notes/mentions'; + this.connection = markRaw(os.stream.useChannel('main')); + this.connection.on('mention', prepend); + } else if (this.src == 'directs') { + endpoint = 'notes/mentions'; + this.query = { + visibility: 'specified' + }; + const onNote = note => { + if (note.visibility == 'specified') { + prepend(note); + } + }; + this.connection = markRaw(os.stream.useChannel('main')); + this.connection.on('mention', onNote); + } else if (this.src == 'list') { + endpoint = 'notes/user-list-timeline'; + this.query = { + listId: this.list + }; + this.connection = markRaw(os.stream.useChannel('userList', { + listId: this.list + })); + this.connection.on('note', prepend); + this.connection.on('userAdded', onUserAdded); + this.connection.on('userRemoved', onUserRemoved); + } else if (this.src == 'channel') { + endpoint = 'channels/timeline'; + this.query = { + channelId: this.channel + }; + this.connection = markRaw(os.stream.useChannel('channel', { + channelId: this.channel + })); + this.connection.on('note', prepend); + } + + this.pagination = { + endpoint: endpoint, + limit: 10, + params: init => ({ + untilDate: this.date?.getTime(), + ...this.baseQuery, ...this.query + }) + }; + }, + + beforeUnmount() { + this.connection.dispose(); + if (this.connection2) this.connection2.dispose(); + }, + + methods: { + focus() { + this.$refs.tl.focus(); + }, + + timetravel(date?: Date) { + this.date = date; + this.$refs.tl.reload(); + } + } +}); +</script> diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue new file mode 100644 index 0000000000..fb0de68092 --- /dev/null +++ b/packages/client/src/components/toast.vue @@ -0,0 +1,73 @@ +<template> +<div class="mk-toast"> + <transition name="notification-slide" appear @after-leave="$emit('closed')"> + <XNotification :notification="notification" class="notification _acrylic" v-if="showing"/> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNotification from './notification.vue'; + +export default defineComponent({ + components: { + XNotification + }, + props: { + notification: { + type: Object, + required: true + } + }, + emits: ['closed'], + data() { + return { + showing: true + }; + }, + mounted() { + setTimeout(() => { + this.showing = false; + }, 6000); + } +}); +</script> + +<style lang="scss" scoped> +.notification-slide-enter-active, .notification-slide-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.notification-slide-enter-from, .notification-slide-leave-to { + opacity: 0; + transform: translateX(-250px); +} + +.mk-toast { + position: fixed; + z-index: 10000; + left: 0; + width: 250px; + top: 32px; + padding: 0 32px; + pointer-events: none; + + @media (max-width: 700px) { + top: initial; + bottom: 112px; + padding: 0 16px; + } + + @media (max-width: 500px) { + bottom: 92px; + padding: 0 8px; + } + + > .notification { + height: 100%; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; + overflow: hidden; + } +} +</style> diff --git a/packages/client/src/components/token-generate-window.vue b/packages/client/src/components/token-generate-window.vue new file mode 100644 index 0000000000..bf5775d4d8 --- /dev/null +++ b/packages/client/src/components/token-generate-window.vue @@ -0,0 +1,117 @@ +<template> +<XModalWindow ref="dialog" + :width="400" + :height="450" + :with-ok-button="true" + :ok-button-disabled="false" + :can-close="false" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>{{ title || $ts.generateAccessToken }}</template> + <div v-if="information" class="_section"> + <MkInfo warn>{{ information }}</MkInfo> + </div> + <div class="_section"> + <MkInput v-model="name"> + <template #label>{{ $ts.name }}</template> + </MkInput> + </div> + <div class="_section"> + <div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div> + <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> + <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { permissions } from 'misskey-js'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkInput from './form/input.vue'; +import MkTextarea from './form/textarea.vue'; +import MkSwitch from './form/switch.vue'; +import MkButton from './ui/button.vue'; +import MkInfo from './ui/info.vue'; + +export default defineComponent({ + components: { + XModalWindow, + MkInput, + MkTextarea, + MkSwitch, + MkButton, + MkInfo, + }, + + props: { + title: { + type: String, + required: false, + default: null + }, + information: { + type: String, + required: false, + default: null + }, + initialName: { + type: String, + required: false, + default: null + }, + initialPermissions: { + type: Array, + required: false, + default: null + } + }, + + emits: ['done', 'closed'], + + data() { + return { + name: this.initialName, + permissions: {}, + kinds: permissions + }; + }, + + created() { + if (this.initialPermissions) { + for (const kind of this.initialPermissions) { + this.permissions[kind] = true; + } + } else { + for (const kind of this.kinds) { + this.permissions[kind] = false; + } + } + }, + + methods: { + ok() { + this.$emit('done', { + name: this.name, + permissions: Object.keys(this.permissions).filter(p => this.permissions[p]) + }); + this.$refs.dialog.close(); + }, + + disableAll() { + for (const p in this.permissions) { + this.permissions[p] = false; + } + }, + + enableAll() { + for (const p in this.permissions) { + this.permissions[p] = true; + } + } + } +}); +</script> diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue new file mode 100644 index 0000000000..b5f4547c84 --- /dev/null +++ b/packages/client/src/components/ui/button.vue @@ -0,0 +1,262 @@ +<template> +<button v-if="!link" class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" + :type="type" + @click="$emit('click', $event)" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</button> +<MkA v-else class="bghgjjyj _button" + :class="{ inline, primary, gradate, danger, rounded, full }" + :to="to" + @mousedown="onMousedown" +> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> +</MkA> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + type: { + type: String, + required: false + }, + primary: { + type: Boolean, + required: false, + default: false + }, + gradate: { + type: Boolean, + required: false, + default: false + }, + rounded: { + type: Boolean, + required: false, + default: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + link: { + type: Boolean, + required: false, + default: false + }, + to: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + wait: { + type: Boolean, + required: false, + default: false + }, + danger: { + type: Boolean, + required: false, + default: false + }, + full: { + type: Boolean, + required: false, + default: false + }, + }, + emits: ['click'], + mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$el.focus(); + }); + } + }, + methods: { + onMousedown(e: MouseEvent) { + function distance(p, q) { + return Math.hypot(p.x - q.x, p.y - q.y); + } + + function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { + const origin = {x: circleCenterX, y: circleCenterY}; + const dist1 = distance({x: 0, y: 0}, origin); + const dist2 = distance({x: boxW, y: 0}, origin); + const dist3 = distance({x: 0, y: boxH}, origin); + const dist4 = distance({x: boxW, y: boxH }, origin); + return Math.max(dist1, dist2, dist3, dist4) * 2; + } + + const rect = e.target.getBoundingClientRect(); + + const ripple = document.createElement('div'); + ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px'; + ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px'; + + this.$refs.ripples.appendChild(ripple); + + const circleCenterX = e.clientX - rect.left; + const circleCenterY = e.clientY - rect.top; + + const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); + + setTimeout(() => { + ripple.style.transform = 'scale(' + (scale / 2) + ')'; + }, 1); + setTimeout(() => { + ripple.style.transition = 'all 1s ease'; + ripple.style.opacity = '0'; + }, 1000); + setTimeout(() => { + if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); + }, 2000); + } + } +}); +</script> + +<style lang="scss" scoped> +.bghgjjyj { + position: relative; + z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため + display: block; + min-width: 100px; + width: max-content; + padding: 8px 14px; + text-align: center; + font-weight: normal; + font-size: 0.8em; + line-height: 22px; + box-shadow: none; + text-decoration: none; + background: var(--buttonBg); + border-radius: 4px; + overflow: clip; + box-sizing: border-box; + transition: background 0.1s ease; + + &:not(:disabled):hover { + background: var(--buttonHoverBg); + } + + &:not(:disabled):active { + background: var(--buttonHoverBg); + } + + &.full { + width: 100%; + } + + &.rounded { + border-radius: 999px; + } + + &.primary { + font-weight: bold; + color: var(--fgOnAccent) !important; + background: var(--accent); + + &:not(:disabled):hover { + background: var(--X8); + } + + &:not(:disabled):active { + background: var(--X8); + } + } + + &.gradate { + font-weight: bold; + color: var(--fgOnAccent) !important; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + + &:not(:disabled):hover { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + + &:not(:disabled):active { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + } + + &.danger { + color: #ff2a2a; + + &.primary { + color: #fff; + background: #ff2a2a; + + &:not(:disabled):hover { + background: #ff4242; + } + + &:not(:disabled):active { + background: #d42e2e; + } + } + } + + &:disabled { + opacity: 0.7; + } + + &:focus-visible { + outline: solid 2px var(--focus); + outline-offset: 2px; + } + + &.inline { + display: inline-block; + width: auto; + min-width: 100px; + } + + > .ripples { + position: absolute; + z-index: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 6px; + overflow: hidden; + + ::v-deep(div) { + position: absolute; + width: 2px; + height: 2px; + border-radius: 100%; + background: rgba(0, 0, 0, 0.1); + opacity: 1; + transform: scale(1); + transition: all 0.5s cubic-bezier(0,.5,0,1); + } + } + + &.primary > .ripples ::v-deep(div) { + background: rgba(0, 0, 0, 0.15); + } + + > .content { + position: relative; + z-index: 1; + } +} +</style> diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue new file mode 100644 index 0000000000..14673dfcd7 --- /dev/null +++ b/packages/client/src/components/ui/container.vue @@ -0,0 +1,262 @@ +<template> +<div class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }"> + <header v-if="showHeader" ref="header"> + <div class="title"><slot name="header"></slot></div> + <div class="sub"> + <slot name="func"></slot> + <button class="_button" v-if="foldable" @click="() => showBody = !showBody"> + <template v-if="showBody"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + </div> + </header> + <transition name="container-toggle" + @enter="enter" + @after-enter="afterEnter" + @leave="leave" + @after-leave="afterLeave" + > + <div v-show="showBody" class="content" :class="{ omitted }" ref="content"> + <slot></slot> + <button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }"> + <span>{{ $ts.showMore }}</span> + </button> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + showHeader: { + type: Boolean, + required: false, + default: true + }, + thin: { + type: Boolean, + required: false, + default: false + }, + naked: { + type: Boolean, + required: false, + default: false + }, + foldable: { + type: Boolean, + required: false, + default: false + }, + expanded: { + type: Boolean, + required: false, + default: true + }, + scrollable: { + type: Boolean, + required: false, + default: false + }, + maxHeight: { + type: Number, + required: false, + default: null + }, + }, + data() { + return { + showBody: this.expanded, + omitted: null, + ignoreOmit: false, + }; + }, + mounted() { + this.$watch('showBody', showBody => { + const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; + this.$el.style.minHeight = `${headerHeight}px`; + if (showBody) { + this.$el.style.flexBasis = `auto`; + } else { + this.$el.style.flexBasis = `${headerHeight}px`; + } + }, { + immediate: true + }); + + this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); + + const calcOmit = () => { + if (this.omitted || this.ignoreOmit || this.maxHeight == null) return; + const height = this.$refs.content.offsetHeight; + this.omitted = height > this.maxHeight; + }; + + calcOmit(); + new ResizeObserver((entries, observer) => { + calcOmit(); + }).observe(this.$refs.content); + }, + methods: { + toggleContent(show: boolean) { + if (!this.foldable) return; + this.showBody = show; + }, + + enter(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = elementHeight + 'px'; + }, + afterEnter(el) { + el.style.height = null; + }, + leave(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; + }, + afterLeave(el) { + el.style.height = null; + }, + } +}); +</script> + +<style lang="scss" scoped> +.container-toggle-enter-active, .container-toggle-leave-active { + overflow-y: hidden; + transition: opacity 0.5s, height 0.5s !important; +} +.container-toggle-enter-from { + opacity: 0; +} +.container-toggle-leave-to { + opacity: 0; +} + +.ukygtjoj { + position: relative; + overflow: clip; + + &.naked { + background: transparent !important; + box-shadow: none !important; + } + + &.scrollable { + display: flex; + flex-direction: column; + + > .content { + overflow: auto; + } + } + + > header { + position: sticky; + top: var(--stickyTop, 0px); + left: 0; + color: var(--panelHeaderFg); + background: var(--panelHeaderBg); + border-bottom: solid 0.5px var(--panelHeaderDivider); + z-index: 2; + line-height: 1.4em; + + > .title { + margin: 0; + padding: 12px 16px; + + > ::v-deep(i) { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .sub { + position: absolute; + z-index: 2; + top: 0; + right: 0; + height: 100%; + + > ::v-deep(button) { + width: 42px; + height: 100%; + } + } + } + + > .content { + --stickyTop: 0px; + + &.omitted { + position: relative; + max-height: var(--maxHeight); + overflow: hidden; + + > .fade { + display: block; + position: absolute; + z-index: 10; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + + > span { + display: inline-block; + background: var(--panel); + padding: 6px 10px; + font-size: 0.8em; + border-radius: 999px; + box-shadow: 0 2px 6px rgb(0 0 0 / 20%); + } + + &:hover { + > span { + background: var(--panelHighlight); + } + } + } + } + } + + &.max-width_380px, &.thin { + > header { + > .title { + padding: 8px 10px; + font-size: 0.9em; + } + } + + > .content { + } + } +} + +._forceContainerFull_ .ukygtjoj { + > header { + > .title { + padding: 12px 16px !important; + } + } +} + +._forceContainerFull_.ukygtjoj { + > header { + > .title { + padding: 12px 16px !important; + } + } +} +</style> diff --git a/packages/client/src/components/ui/context-menu.vue b/packages/client/src/components/ui/context-menu.vue new file mode 100644 index 0000000000..561099cbe0 --- /dev/null +++ b/packages/client/src/components/ui/context-menu.vue @@ -0,0 +1,97 @@ +<template> +<transition :name="$store.state.animation ? 'fade' : ''" appear> + <div class="nvlagfpb" @contextmenu.prevent.stop="() => {}"> + <MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import contains from '@/scripts/contains'; +import MkMenu from './menu.vue'; + +export default defineComponent({ + components: { + MkMenu, + }, + props: { + items: { + type: Array, + required: true + }, + ev: { + required: true + }, + viaKeyboard: { + type: Boolean, + required: false + }, + }, + emits: ['closed'], + computed: { + keymap(): any { + return { + 'esc': () => this.$emit('closed'), + }; + }, + }, + mounted() { + let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 + let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 + + const width = this.$el.offsetWidth; + const height = this.$el.offsetHeight; + + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset; + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + this.$el.style.top = top + 'px'; + this.$el.style.left = left + 'px'; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', this.onMousedown); + } + }, + beforeUnmount() { + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + methods: { + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.nvlagfpb { + position: absolute; + z-index: 65535; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: left top; +} + +.fade-enter-from, .fade-leave-to { + opacity: 0; + transform: scale(0.9); +} +</style> diff --git a/packages/client/src/components/ui/folder.vue b/packages/client/src/components/ui/folder.vue new file mode 100644 index 0000000000..3997421d08 --- /dev/null +++ b/packages/client/src/components/ui/folder.vue @@ -0,0 +1,156 @@ +<template> +<div class="ssazuxis" v-size="{ max: [500] }"> + <header @click="showBody = !showBody" class="_button" :style="{ background: bg }"> + <div class="title"><slot name="header"></slot></div> + <div class="divider"></div> + <button class="_button"> + <template v-if="showBody"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + </header> + <transition name="folder-toggle" + @enter="enter" + @after-enter="afterEnter" + @leave="leave" + @after-leave="afterLeave" + > + <div v-show="showBody"> + <slot></slot> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; + +const localStoragePrefix = 'ui:folder:'; + +export default defineComponent({ + props: { + expanded: { + type: Boolean, + required: false, + default: true + }, + persistKey: { + type: String, + required: false, + default: null + }, + }, + data() { + return { + bg: null, + showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded, + }; + }, + watch: { + showBody() { + if (this.persistKey) { + localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f'); + } + } + }, + mounted() { + function getParentBg(el: Element | null): string { + if (el == null || el.tagName === 'BODY') return 'var(--bg)'; + const bg = el.style.background || el.style.backgroundColor; + if (bg) { + return bg; + } else { + return getParentBg(el.parentElement); + } + } + const rawBg = getParentBg(this.$el); + const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + bg.setAlpha(0.85); + this.bg = bg.toRgbString(); + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + }, + + enter(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = elementHeight + 'px'; + }, + afterEnter(el) { + el.style.height = null; + }, + leave(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; + }, + afterLeave(el) { + el.style.height = null; + }, + } +}); +</script> + +<style lang="scss" scoped> +.folder-toggle-enter-active, .folder-toggle-leave-active { + overflow-y: hidden; + transition: opacity 0.5s, height 0.5s !important; +} +.folder-toggle-enter-from { + opacity: 0; +} +.folder-toggle-leave-to { + opacity: 0; +} + +.ssazuxis { + position: relative; + + > header { + display: flex; + position: relative; + z-index: 10; + position: sticky; + top: var(--stickyTop, 0px); + padding: var(--x-padding); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(20px)); + + > .title { + margin: 0; + padding: 12px 16px 12px 0; + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .divider { + flex: 1; + margin: auto; + height: 1px; + background: var(--divider); + } + + > button { + padding: 12px 0 12px 16px; + } + } + + &.max-width_500px { + > header { + > .title { + padding: 8px 10px 8px 0; + } + } + } +} +</style> diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue new file mode 100644 index 0000000000..6b075cb440 --- /dev/null +++ b/packages/client/src/components/ui/hr.vue @@ -0,0 +1,16 @@ +<template> +<div class="evrzpitu"></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue';import * as os from '@/os'; + +export default defineComponent({}); +</script> + +<style lang="scss" scoped> +.evrzpitu + margin 16px 0 + border-bottom solid var(--lineWidth) var(--faceDivider) + +</style> diff --git a/packages/client/src/components/ui/info.vue b/packages/client/src/components/ui/info.vue new file mode 100644 index 0000000000..8f5986baf7 --- /dev/null +++ b/packages/client/src/components/ui/info.vue @@ -0,0 +1,45 @@ +<template> +<div class="fpezltsf" :class="{ warn }"> + <i v-if="warn" class="fas fa-exclamation-triangle"></i> + <i v-else class="fas fa-info-circle"></i> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + warn: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.fpezltsf { + padding: 16px; + font-size: 90%; + background: var(--infoBg); + color: var(--infoFg); + border-radius: var(--radius); + + &.warn { + background: var(--infoWarnBg); + color: var(--infoWarnFg); + } + + > i { + margin-right: 4px; + } +} +</style> diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue new file mode 100644 index 0000000000..5938fb00a1 --- /dev/null +++ b/packages/client/src/components/ui/menu.vue @@ -0,0 +1,278 @@ +<template> +<div class="rrevdjwt" :class="{ center: align === 'center' }" + :style="{ width: width ? width + 'px' : null }" + ref="items" + @contextmenu.self="e => e.preventDefault()" + v-hotkey="keymap" +> + <template v-for="(item, i) in _items"> + <div v-if="item === null" class="divider"></div> + <span v-else-if="item.type === 'label'" class="label item"> + <span>{{ item.text }}</span> + </span> + <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> + <span><MkEllipsis/></span> + </span> + <MkA v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </MkA> + <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </a> + <button v-else-if="item.type === 'user'" @click="clicked(item.action, $event)" :tabindex="i" class="_button item"> + <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + <button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> + <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> + <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> + <span>{{ item.text }}</span> + <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> + </button> + </template> + <span v-if="_items.length === 0" class="none item"> + <span>{{ $ts.none }}</span> + </span> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, unref } from 'vue'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import contains from '@/scripts/contains'; + +export default defineComponent({ + props: { + items: { + type: Array, + required: true + }, + viaKeyboard: { + type: Boolean, + required: false + }, + align: { + type: String, + requried: false + }, + width: { + type: Number, + required: false + }, + }, + emits: ['close'], + data() { + return { + _items: [], + }; + }, + computed: { + keymap(): any { + return { + 'up|k|shift+tab': this.focusUp, + 'down|j|tab': this.focusDown, + 'esc': this.close, + }; + }, + }, + watch: { + items: { + handler() { + const items = ref(unref(this.items).filter(item => item !== undefined)); + + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + + if (item && item.then) { // if item is Promise + items.value[i] = { type: 'pending' }; + item.then(actualItem => { + items.value[i] = actualItem; + }); + } + } + + this._items = items; + }, + immediate: true + } + }, + mounted() { + if (this.viaKeyboard) { + this.$nextTick(() => { + focusNext(this.$refs.items.children[0], true, false); + }); + } + + if (this.contextmenuEvent) { + this.$el.style.top = this.contextmenuEvent.pageY + 'px'; + this.$el.style.left = this.contextmenuEvent.pageX + 'px'; + + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.addEventListener('mousedown', this.onMousedown); + } + } + }, + beforeUnmount() { + for (const el of Array.from(document.querySelectorAll('body *'))) { + el.removeEventListener('mousedown', this.onMousedown); + } + }, + methods: { + clicked(fn, ev) { + fn(ev); + this.close(); + }, + close() { + this.$emit('close'); + }, + focusUp() { + focusPrev(document.activeElement); + }, + focusDown() { + focusNext(document.activeElement); + }, + onMousedown(e) { + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.rrevdjwt { + padding: 8px 0; + min-width: 200px; + max-height: 90vh; + overflow: auto; + + &.center { + > .item { + text-align: center; + } + } + + > .item { + display: block; + position: relative; + padding: 8px 18px; + width: 100%; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.9em; + line-height: 20px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + + &:before { + content: ""; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + margin: auto; + width: calc(100% - 16px); + height: 100%; + border-radius: 6px; + } + + > * { + position: relative; + } + + &.danger { + color: #ff2a2a; + + &:hover { + color: #fff; + + &:before { + background: #ff4242; + } + } + + &:active { + color: #fff; + + &:before { + background: #d42e2e; + } + } + } + + &.active { + color: var(--fgOnAccent); + opacity: 1; + + &:before { + background: var(--accent); + } + } + + &:not(:disabled):hover { + color: var(--accent); + text-decoration: none; + + &:before { + background: var(--accentedBg); + } + } + + &:not(:active):focus-visible { + box-shadow: 0 0 0 2px var(--focus) inset; + } + + &.label { + pointer-events: none; + font-size: 0.7em; + padding-bottom: 4px; + + > span { + opacity: 0.7; + } + } + + &.pending { + pointer-events: none; + opacity: 0.7; + } + + &.none { + pointer-events: none; + opacity: 0.7; + } + + > i { + margin-right: 5px; + width: 20px; + } + + > .avatar { + margin-right: 5px; + width: 20px; + height: 20px; + } + + > .indicator { + position: absolute; + top: 5px; + left: 13px; + color: var(--indicator); + font-size: 12px; + animation: blink 1s infinite; + } + } + + > .divider { + margin: 8px 0; + height: 1px; + background: var(--divider); + } +} +</style> diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue new file mode 100644 index 0000000000..da98192b87 --- /dev/null +++ b/packages/client/src/components/ui/modal-window.vue @@ -0,0 +1,148 @@ +<template> +<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> + <div class="ebkgoccj _window _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }"> + <div class="header"> + <button class="_button" v-if="withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button> + <span class="title"> + <slot name="header"></slot> + </span> + <button class="_button" v-if="!withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button> + <button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><i class="fas fa-check"></i></button> + </div> + <div class="body" v-if="padding"> + <div class="_section"> + <slot></slot> + </div> + </div> + <div class="body" v-else> + <slot></slot> + </div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from './modal.vue'; + +export default defineComponent({ + components: { + MkModal + }, + props: { + withOkButton: { + type: Boolean, + required: false, + default: false + }, + okButtonDisabled: { + type: Boolean, + required: false, + default: false + }, + padding: { + type: Boolean, + required: false, + default: false + }, + width: { + type: Number, + required: false, + default: 400 + }, + height: { + type: Number, + required: false, + default: null + }, + canClose: { + type: Boolean, + required: false, + default: true, + }, + scroll: { + type: Boolean, + required: false, + default: true, + }, + }, + + emits: ['click', 'close', 'closed', 'ok'], + + data() { + return { + }; + }, + + methods: { + close() { + this.$refs.modal.close(); + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.ebkgoccj { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + + --root-margin: 24px; + + @media (max-width: 500px) { + --root-margin: 16px; + } + + > .header { + $height: 58px; + $height-narrow: 42px; + display: flex; + flex-shrink: 0; + box-shadow: 0px 1px var(--divider); + + > button { + height: $height; + width: $height; + + @media (max-width: 500px) { + height: $height-narrow; + width: $height-narrow; + } + } + + > .title { + flex: 1; + line-height: $height; + padding-left: 32px; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; + + @media (max-width: 500px) { + line-height: $height-narrow; + padding-left: 16px; + } + } + + > button + .title { + padding-left: 0; + } + } + + > .body { + overflow: auto; + } +} +</style> diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue new file mode 100644 index 0000000000..33fcdb687f --- /dev/null +++ b/packages/client/src/components/ui/modal.vue @@ -0,0 +1,292 @@ +<template> +<transition :name="$store.state.animation ? popup ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? popup ? 500 : 300 : 0" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> + <div v-show="manualShowing != null ? manualShowing : showing" class="qzhlnise" :class="{ front }" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> + <div class="bg _modalBg" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div> + <div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content"> + <slot></slot> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +function getFixedContainer(el: Element | null): Element | null { + if (el == null || el.tagName === 'BODY') return null; + const position = window.getComputedStyle(el).getPropertyValue('position'); + if (position === 'fixed') { + return el; + } else { + return getFixedContainer(el.parentElement); + } +} + +export default defineComponent({ + provide: { + modal: true + }, + props: { + manualShowing: { + type: Boolean, + required: false, + default: null, + }, + srcCenter: { + type: Boolean, + required: false + }, + src: { + required: false, + }, + position: { + required: false + }, + front: { + type: Boolean, + required: false, + default: false, + } + }, + emits: ['opening', 'click', 'esc', 'close', 'closed'], + data() { + return { + showing: true, + fixed: false, + transformOrigin: 'center', + contentClicking: false, + }; + }, + computed: { + keymap(): any { + return { + 'esc': () => this.$emit('esc'), + }; + }, + popup(): boolean { + return this.src != null; + } + }, + mounted() { + this.$watch('src', () => { + this.fixed = getFixedContainer(this.src) != null; + this.$nextTick(() => { + this.align(); + }); + }, { immediate: true }); + + this.$nextTick(() => { + const popover = this.$refs.content as any; + new ResizeObserver((entries, observer) => { + this.align(); + }).observe(popover); + }); + }, + methods: { + align() { + if (!this.popup) return; + + const popover = this.$refs.content as any; + + if (popover == null) return; + + const rect = this.src.getBoundingClientRect(); + + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.srcCenter) { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + } else { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (this.fixed) { + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + } + + if (top + height > window.innerHeight) { + top = window.innerHeight - height; + } + } else { + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset - 1; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset - 1; + } + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { + this.transformOrigin = 'center top'; + } else { + this.transformOrigin = 'center'; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + }, + + childRendered() { + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const content = this.$refs.content.children[0]; + content.addEventListener('mousedown', e => { + this.contentClicking = true; + window.addEventListener('mouseup', e => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + setTimeout(() => { + this.contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); + }, + + close() { + this.showing = false; + this.$emit('close'); + }, + + onBgClick() { + if (this.contentClicking) return; + this.$emit('click'); + }, + + onClosed() { + this.$emit('closed'); + } + } +}); +</script> + +<style lang="scss"> +.modal-popup-enter-active, .modal-popup-leave-active, +.modal-enter-from, .modal-leave-to { + > .content { + transform-origin: var(--transformOrigin); + } +} +</style> + +<style lang="scss" scoped> +.modal-enter-active, .modal-leave-active { + > .bg { + transition: opacity 0.3s !important; + } + + > .content { + transition: opacity 0.3s, transform 0.3s !important; + } +} +.modal-enter-from, .modal-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + opacity: 0; + transform: scale(0.9); + } +} + +.modal-popup-enter-active, .modal-popup-leave-active { + > .bg { + transition: opacity 0.3s !important; + } + + > .content { + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1) !important; + } +} +.modal-popup-enter-from, .modal-popup-leave-to { + > .bg { + opacity: 0; + } + + > .content { + pointer-events: none; + opacity: 0; + transform: scale(0.9); + } +} + +.qzhlnise { + > .bg { + z-index: 10000; + } + + > .content:not(.popup) { + position: fixed; + z-index: 10000; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; + padding: 32px; + // TODO: mask-imageはiOSだとやたら重い。なんとかしたい + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); + overflow: auto; + display: flex; + + @media (max-width: 500px) { + padding: 16px; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); + } + + > ::v-deep(*) { + margin: auto; + } + + &.top { + > ::v-deep(*) { + margin-top: 0; + } + } + } + + > .content.popup { + position: absolute; + z-index: 10000; + + &.fixed { + position: fixed; + } + } + + &.front { + > .bg { + z-index: 20000; + } + + > .content:not(.popup) { + z-index: 20000; + } + + > .content.popup { + z-index: 20000; + } + } +} +</style> diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue new file mode 100644 index 0000000000..f6a457d88f --- /dev/null +++ b/packages/client/src/components/ui/pagination.vue @@ -0,0 +1,69 @@ +<template> +<transition name="fade" mode="out-in"> + <MkLoading v-if="fetching"/> + + <MkError v-else-if="error" @retry="init()"/> + + <div class="empty" v-else-if="empty" key="_empty_"> + <slot name="empty"></slot> + </div> + + <div v-else class="cxiknjgy"> + <slot :items="items"></slot> + <div class="more _gap" v-show="more" key="_more_"> + <MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </MkButton> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from './button.vue'; +import paging from '@/scripts/paging'; + +export default defineComponent({ + components: { + MkButton + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + + disableAutoLoad: { + type: Boolean, + required: false, + default: false, + } + }, +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.cxiknjgy { + > .more > .button { + margin-left: auto; + margin-right: auto; + height: 48px; + min-width: 150px; + } +} +</style> diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/ui/popup-menu.vue new file mode 100644 index 0000000000..3ff4c658b1 --- /dev/null +++ b/packages/client/src/components/ui/popup-menu.vue @@ -0,0 +1,42 @@ +<template> +<MkPopup ref="popup" :src="src" @closed="$emit('closed')"> + <MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/> +</MkPopup> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPopup from './popup.vue'; +import MkMenu from './menu.vue'; + +export default defineComponent({ + components: { + MkPopup, + MkMenu, + }, + + props: { + items: { + type: Array, + required: true + }, + align: { + type: String, + required: false + }, + width: { + type: Number, + required: false + }, + viaKeyboard: { + type: Boolean, + required: false + }, + src: { + required: false + }, + }, + + emits: ['close', 'closed'], +}); +</script> diff --git a/packages/client/src/components/ui/popup.vue b/packages/client/src/components/ui/popup.vue new file mode 100644 index 0000000000..0fb1780cc5 --- /dev/null +++ b/packages/client/src/components/ui/popup.vue @@ -0,0 +1,213 @@ +<template> +<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> + <div v-show="manualShowing != null ? manualShowing : showing" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" ref="content" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> + <slot></slot> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent, PropType } from 'vue'; + +function getFixedContainer(el: Element | null): Element | null { + if (el == null || el.tagName === 'BODY') return null; + const position = window.getComputedStyle(el).getPropertyValue('position'); + if (position === 'fixed') { + return el; + } else { + return getFixedContainer(el.parentElement); + } +} + +export default defineComponent({ + props: { + manualShowing: { + type: Boolean, + required: false, + default: null, + }, + srcCenter: { + type: Boolean, + required: false + }, + src: { + type: Object as PropType<HTMLElement>, + required: false, + }, + position: { + required: false + }, + front: { + type: Boolean, + required: false, + default: false, + } + }, + + emits: ['opening', 'click', 'esc', 'close', 'closed'], + + data() { + return { + showing: true, + fixed: false, + transformOrigin: 'center', + contentClicking: false, + }; + }, + + mounted() { + this.$watch('src', () => { + if (this.src) { + this.src.style.pointerEvents = 'none'; + } + this.fixed = getFixedContainer(this.src) != null; + this.$nextTick(() => { + this.align(); + }); + }, { immediate: true }); + + this.$nextTick(() => { + const popover = this.$refs.content as any; + new ResizeObserver((entries, observer) => { + this.align(); + }).observe(popover); + }); + + document.addEventListener('mousedown', this.onDocumentClick, { passive: true }); + }, + + beforeUnmount() { + document.removeEventListener('mousedown', this.onDocumentClick); + }, + + methods: { + align() { + if (this.src == null) return; + + const popover = this.$refs.content as any; + + if (popover == null) return; + + const rect = this.src.getBoundingClientRect(); + + const width = popover.offsetWidth; + const height = popover.offsetHeight; + + let left; + let top; + + if (this.srcCenter) { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); + left = (x - (width / 2)); + top = (y - (height / 2)); + } else { + const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); + const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; + left = (x - (width / 2)); + top = y; + } + + if (this.fixed) { + if (left + width > window.innerWidth) { + left = window.innerWidth - width; + } + + if (top + height > window.innerHeight) { + top = window.innerHeight - height; + } + } else { + if (left + width - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - width + window.pageXOffset - 1; + } + + if (top + height - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - height + window.pageYOffset - 1; + } + } + + if (top < 0) { + top = 0; + } + + if (left < 0) { + left = 0; + } + + if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { + this.transformOrigin = 'center top'; + } else { + this.transformOrigin = 'center'; + } + + popover.style.left = left + 'px'; + popover.style.top = top + 'px'; + }, + + childRendered() { + // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する + const content = this.$refs.content.children[0]; + content.addEventListener('mousedown', e => { + this.contentClicking = true; + window.addEventListener('mouseup', e => { + // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ + setTimeout(() => { + this.contentClicking = false; + }, 100); + }, { passive: true, once: true }); + }, { passive: true }); + }, + + close() { + if (this.src) this.src.style.pointerEvents = 'auto'; + this.showing = false; + this.$emit('close'); + }, + + onClosed() { + this.$emit('closed'); + }, + + onDocumentClick(ev) { + const flyoutElement = this.$refs.content; + let targetElement = ev.target; + do { + if (targetElement === flyoutElement) { + return; + } + targetElement = targetElement.parentNode; + } while (targetElement); + this.close(); + } + } +}); +</script> + +<style lang="scss" scoped> +.popup-menu-enter-active { + transform-origin: var(--transformOrigin); + transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important; +} +.popup-menu-leave-active { + transform-origin: var(--transformOrigin); + transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), transform 0.2s cubic-bezier(0.4, 0, 1, 1) !important; +} +.popup-menu-enter-from, .popup-menu-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.ccczpooj { + position: absolute; + z-index: 10000; + + &.fixed { + position: fixed; + } + + &.front { + z-index: 20000; + } +} +</style> diff --git a/packages/client/src/components/ui/super-menu.vue b/packages/client/src/components/ui/super-menu.vue new file mode 100644 index 0000000000..195cc57326 --- /dev/null +++ b/packages/client/src/components/ui/super-menu.vue @@ -0,0 +1,148 @@ +<template> +<div class="rrevdjwu" :class="{ grid }"> + <div class="group" v-for="group in def"> + <div class="title" v-if="group.title">{{ group.title }}</div> + + <div class="items"> + <template v-for="(item, i) in group.items"> + <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </a> + <button v-else-if="item.type === 'button'" @click="ev => item.action(ev)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </button> + <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> + <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> + <span class="text">{{ item.text }}</span> + </MkA> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, ref, unref } from 'vue'; + +export default defineComponent({ + props: { + def: { + type: Array, + required: true + }, + grid: { + type: Boolean, + required: false, + default: false, + }, + }, +}); +</script> + +<style lang="scss" scoped> +.rrevdjwu { + > .group { + & + .group { + margin-top: 16px; + padding-top: 16px; + border-top: solid 0.5px var(--divider); + } + + > .title { + font-size: 0.9em; + opacity: 0.7; + margin: 0 0 8px 12px; + } + + > .items { + > .item { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 10px 16px 10px 8px; + border-radius: 9px; + font-size: 0.9em; + + &:hover { + text-decoration: none; + background: var(--panelHighlight); + } + + &.active { + color: var(--accent); + background: var(--accentedBg); + } + + &.danger { + color: var(--error); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + } + + > .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 12px; + } + + } + } + } + + &.grid { + > .group { + & + .group { + padding-top: 0; + border-top: none; + } + + margin-left: 0; + margin-right: 0; + + > .title { + font-size: 1em; + opacity: 0.7; + margin: 0 0 8px 16px; + } + + > .items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + grid-gap: 8px; + padding: 0 16px; + + > .item { + flex-direction: column; + padding: 18px 16px 16px 16px; + background: var(--panel); + border-radius: 8px; + text-align: center; + + > .icon { + display: block; + margin-right: 0; + margin-bottom: 12px; + font-size: 1.5em; + } + + > .text { + padding-right: 0; + width: 100%; + font-size: 0.8em; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue new file mode 100644 index 0000000000..c003895c14 --- /dev/null +++ b/packages/client/src/components/ui/tooltip.vue @@ -0,0 +1,92 @@ +<template> +<transition name="tooltip" appear @after-leave="$emit('closed')"> + <div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content" :style="{ maxWidth: maxWidth + 'px' }"> + <slot>{{ text }}</slot> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + showing: { + type: Boolean, + required: true, + }, + source: { + required: true, + }, + text: { + type: String, + required: false + }, + maxWidth: { + type: Number, + required: false, + default: 250, + }, + }, + + emits: ['closed'], + + mounted() { + this.$nextTick(() => { + if (this.source == null) { + this.$emit('closed'); + return; + } + + const rect = this.source.getBoundingClientRect(); + + const contentWidth = this.$refs.content.offsetWidth; + const contentHeight = this.$refs.content.offsetHeight; + + let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); + let top = rect.top + window.pageYOffset - contentHeight; + + left -= (this.$el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + if (top - window.pageYOffset < 0) { + top = rect.top + window.pageYOffset + this.source.offsetHeight; + this.$refs.content.style.transformOrigin = 'center top'; + } + + this.$el.style.left = left + 'px'; + this.$el.style.top = top + 'px'; + }); + }, +}) +</script> + +<style lang="scss" scoped> +.tooltip-enter-active, +.tooltip-leave-active { + opacity: 1; + transform: scale(1); + transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tooltip-enter-from, +.tooltip-leave-active { + opacity: 0; + transform: scale(0.75); +} + +.buebdbiu { + position: absolute; + z-index: 11000; + font-size: 0.8em; + padding: 8px 12px; + box-sizing: border-box; + text-align: center; + border-radius: 4px; + border: solid 0.5px var(--divider); + pointer-events: none; + transform-origin: center bottom; +} +</style> diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue new file mode 100644 index 0000000000..b7093b6641 --- /dev/null +++ b/packages/client/src/components/ui/window.vue @@ -0,0 +1,525 @@ +<template> +<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> + <div class="ebkgocck" :class="{ front }" v-if="showing"> + <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> + <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> + <span class="left"> + <slot name="headerLeft"></slot> + </span> + <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> + <slot name="header"></slot> + </span> + <span class="right"> + <slot name="headerRight"></slot> + <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button> + </span> + </div> + <div class="body" v-if="padding"> + <div class="_section"> + <slot></slot> + </div> + </div> + <div class="body" v-else> + <slot></slot> + </div> + </div> + <template v-if="canResize"> + <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </template> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import contains from '@/scripts/contains'; +import * as os from '@/os'; + +const minHeight = 50; +const minWidth = 250; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('touchmove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); + window.addEventListener('touchend', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('touchmove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); + window.removeEventListener('touchend', dragClear); +} + +export default defineComponent({ + provide: { + inWindow: true + }, + + props: { + padding: { + type: Boolean, + required: false, + default: false + }, + initialWidth: { + type: Number, + required: false, + default: 400 + }, + initialHeight: { + type: Number, + required: false, + default: null + }, + canResize: { + type: Boolean, + required: false, + default: false, + }, + closeButton: { + type: Boolean, + required: false, + default: true, + }, + mini: { + type: Boolean, + required: false, + default: false, + }, + front: { + type: Boolean, + required: false, + default: false, + }, + contextmenu: { + type: Array, + required: false, + } + }, + + emits: ['closed'], + + data() { + return { + showing: true, + id: Math.random().toString(), // TODO: UUIDとかにする + }; + }, + + mounted() { + if (this.initialWidth) this.applyTransformWidth(this.initialWidth); + if (this.initialHeight) this.applyTransformHeight(this.initialHeight); + + this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2)); + this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2)); + + os.windows.set(this.id, { + z: Number(document.defaultView.getComputedStyle(this.$el, null).zIndex) + }); + + // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする + this.top(); + + window.addEventListener('resize', this.onBrowserResize); + }, + + unmounted() { + os.windows.delete(this.id); + window.removeEventListener('resize', this.onBrowserResize); + }, + + methods: { + close() { + this.showing = false; + }, + + onKeydown(e) { + if (e.which === 27) { // Esc + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + }, + + onContextmenu(e) { + if (this.contextmenu) { + os.contextMenu(this.contextmenu, e); + } + }, + + // 最前面へ移動 + top() { + let z = 0; + const ws = Array.from(os.windows.entries()).filter(([k, v]) => k !== this.id).map(([k, v]) => v); + for (const w of ws) { + if (w.z > z) z = w.z; + } + if (z > 0) { + (this.$el as any).style.zIndex = z + 1; + os.windows.set(this.id, { + z: z + 1 + }); + } + }, + + onBodyMousedown() { + this.top(); + }, + + onHeaderMousedown(e) { + const main = this.$el as any; + + if (!contains(main, document.activeElement)) main.focus(); + + const position = main.getBoundingClientRect(); + + const clickX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX; + const clickY = e.touches && e.touches.length > 0 ? e.touches[0].clientY : e.clientY; + const moveBaseX = clickX - position.left; + const moveBaseY = clickY - position.top; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + // 動かした時 + dragListen(me => { + const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; + const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; + + let moveLeft = x - moveBaseX; + let moveTop = y - moveBaseY; + + // 下はみ出し + if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; + + // 左はみ出し + if (moveLeft < 0) moveLeft = 0; + + // 上はみ出し + if (moveTop < 0) moveTop = 0; + + // 右はみ出し + if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; + + this.$el.style.left = moveLeft + 'px'; + this.$el.style.top = moveTop + 'px'; + }); + }, + + // 上ハンドル掴み時 + onTopHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + move > 0) { + if (height + -move > minHeight) { + this.applyTransformHeight(height + -move); + this.applyTransformTop(top + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + this.applyTransformTop(top + (height - minHeight)); + } + } else { // 上のはみ出し時 + this.applyTransformHeight(top + height); + this.applyTransformTop(0); + } + }); + }, + + // 右ハンドル掴み時 + onRightHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + const browserWidth = window.innerWidth; + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + width + move < browserWidth) { + if (width + move > minWidth) { + this.applyTransformWidth(width + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + } + } else { // 右のはみ出し時 + this.applyTransformWidth(browserWidth - left); + } + }); + }, + + // 下ハンドル掴み時 + onBottomHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + const browserHeight = window.innerHeight; + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + height + move < browserHeight) { + if (height + move > minHeight) { + this.applyTransformHeight(height + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + } + } else { // 下のはみ出し時 + this.applyTransformHeight(browserHeight - top); + } + }); + }, + + // 左ハンドル掴み時 + onLeftHandleMousedown(e) { + const main = this.$el as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + move > 0) { + if (width + -move > minWidth) { + this.applyTransformWidth(width + -move); + this.applyTransformLeft(left + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + this.applyTransformLeft(left + (width - minWidth)); + } + } else { // 左のはみ出し時 + this.applyTransformWidth(left + width); + this.applyTransformLeft(0); + } + }); + }, + + // 左上ハンドル掴み時 + onTopLeftHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 右上ハンドル掴み時 + onTopRightHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 右下ハンドル掴み時 + onBottomRightHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 左下ハンドル掴み時 + onBottomLeftHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 高さを適用 + applyTransformHeight(height) { + if (height > window.innerHeight) height = window.innerHeight; + (this.$el as any).style.height = height + 'px'; + }, + + // 幅を適用 + applyTransformWidth(width) { + if (width > window.innerWidth) width = window.innerWidth; + (this.$el as any).style.width = width + 'px'; + }, + + // Y座標を適用 + applyTransformTop(top) { + (this.$el as any).style.top = top + 'px'; + }, + + // X座標を適用 + applyTransformLeft(left) { + (this.$el as any).style.left = left + 'px'; + }, + + onBrowserResize() { + const main = this.$el as any; + const position = main.getBoundingClientRect(); + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + if (position.left < 0) main.style.left = 0; // 左はみ出し + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し + if (position.top < 0) main.style.top = 0; // 上はみ出し + } + } +}); +</script> + +<style lang="scss" scoped> +.window-enter-active, .window-leave-active { + transition: opacity 0.2s, transform 0.2s !important; +} +.window-enter-from, .window-leave-to { + pointer-events: none; + opacity: 0; + transform: scale(0.9); +} + +.ebkgocck { + position: fixed; + top: 0; + left: 0; + z-index: 10000; // mk-modalのと同じでなければならない + + &.front { + z-index: 11000; // front指定の時は、mk-modalのよりも大きくなければならない + } + + > .body { + overflow: hidden; + display: flex; + flex-direction: column; + contain: content; + width: 100%; + height: 100%; + + > .header { + --height: 50px; + + &.mini { + --height: 38px; + } + + display: flex; + position: relative; + z-index: 1; + flex-shrink: 0; + user-select: none; + height: var(--height); + border-bottom: solid 1px var(--divider); + + > .left, > .right { + > ::v-deep(button) { + height: var(--height); + width: var(--height); + + &:hover { + color: var(--fgHighlighted); + } + } + } + + > .title { + flex: 1; + position: relative; + line-height: var(--height); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + cursor: move; + } + } + + > .body { + flex: 1; + overflow: auto; + } + } + + > .handle { + $size: 8px; + + position: absolute; + + &.top { + top: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.right { + top: 0; + right: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.bottom { + bottom: -($size); + left: 0; + width: 100%; + height: $size; + cursor: ns-resize; + } + + &.left { + top: 0; + left: -($size); + width: $size; + height: 100%; + cursor: ew-resize; + } + + &.top-left { + top: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.top-right { + top: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + + &.bottom-right { + bottom: -($size); + right: -($size); + width: $size * 2; + height: $size * 2; + cursor: nwse-resize; + } + + &.bottom-left { + bottom: -($size); + left: -($size); + width: $size * 2; + height: $size * 2; + cursor: nesw-resize; + } + } +} +</style> diff --git a/packages/client/src/components/updated.vue b/packages/client/src/components/updated.vue new file mode 100644 index 0000000000..c021c60669 --- /dev/null +++ b/packages/client/src/components/updated.vue @@ -0,0 +1,62 @@ +<template> +<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="ewlycnyt"> + <div class="title">{{ $ts.misskeyUpdated }}</div> + <div class="version">✨{{ version }}🚀</div> + <MkButton full @click="whatIsNew">{{ $ts.whatIsNew }}</MkButton> + <MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ $ts.gotIt }}</MkButton> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; +import MkButton from '@/components/ui/button.vue'; +import { version } from '@/config'; + +export default defineComponent({ + components: { + MkModal, + MkButton, + }, + + data() { + return { + version: version, + }; + }, + + methods: { + whatIsNew() { + this.$refs.modal.close(); + window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank'); + } + } +}); +</script> + +<style lang="scss" scoped> +.ewlycnyt { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + + > .title { + font-weight: bold; + } + + > .version { + margin: 1em 0; + } + + > .gotIt { + margin: 8px 0 0 0; + } +} +</style> diff --git a/packages/client/src/components/url-preview-popup.vue b/packages/client/src/components/url-preview-popup.vue new file mode 100644 index 0000000000..0a402f793f --- /dev/null +++ b/packages/client/src/components/url-preview-popup.vue @@ -0,0 +1,60 @@ +<template> +<div class="fgmtyycl" :style="{ top: top + 'px', left: left + 'px' }"> + <transition name="zoom" @after-leave="$emit('closed')"> + <MkUrlPreview class="_popup _shadow" :url="url" v-if="showing"/> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkUrlPreview from './url-preview.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkUrlPreview + }, + + props: { + url: { + type: String, + required: true + }, + source: { + required: true + }, + showing: { + type: Boolean, + required: true + }, + }, + + data() { + return { + u: null, + top: 0, + left: 0, + }; + }, + + mounted() { + const rect = this.source.getBoundingClientRect(); + const x = Math.max((rect.left + (this.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; + const y = rect.top + this.source.offsetHeight + window.pageYOffset; + + this.top = y; + this.left = x; + }, +}); +</script> + +<style lang="scss" scoped> +.fgmtyycl { + position: absolute; + z-index: 11000; + width: 500px; + max-width: calc(90vw - 12px); + pointer-events: none; +} +</style> diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue new file mode 100644 index 0000000000..0826ba5ccf --- /dev/null +++ b/packages/client/src/components/url-preview.vue @@ -0,0 +1,334 @@ +<template> +<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> + <button class="disablePlayer" @click="playerEnabled = false" :title="$ts.disablePlayer"><i class="fas fa-times"></i></button> + <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> +</div> +<div v-else-if="tweetId && tweetExpanded" class="twitter" ref="twitter"> + <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> +</div> +<div v-else class="mk-url-preview" v-size="{ max: [400, 350] }"> + <transition name="zoom" mode="out-in"> + <component :is="self ? 'MkA' : 'a'" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching"> + <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"> + <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$ts.enablePlayer"><i class="fas fa-play-circle"></i></button> + </div> + <article> + <header> + <h1 :title="title">{{ title }}</h1> + </header> + <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> + <footer> + <img class="icon" v-if="icon" :src="icon"/> + <p :title="sitename">{{ sitename }}</p> + </footer> + </article> + </component> + </transition> + <div class="expandTweet" v-if="tweetId"> + <a @click="tweetExpanded = true"> + <i class="fab fa-twitter"></i> {{ $ts.expandTweet }} + </a> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { url as local, lang } from '@/config'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + url: { + type: String, + require: true + }, + + detail: { + type: Boolean, + required: false, + default: false + }, + + compact: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + const self = this.url.startsWith(local); + return { + local, + fetching: true, + title: null, + description: null, + thumbnail: null, + icon: null, + sitename: null, + player: { + url: null, + width: null, + height: null + }, + tweetId: null, + tweetExpanded: this.detail, + embedId: `embed${Math.random().toString().replace(/\D/,'')}`, + tweetHeight: 150, + tweetLeft: 0, + playerEnabled: false, + self: self, + attr: self ? 'to' : 'href', + target: self ? null : '_blank', + }; + }, + + created() { + const requestUrl = new URL(this.url); + + if (requestUrl.hostname == 'twitter.com') { + const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/); + if (m) this.tweetId = m[1]; + } + + if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { + requestUrl.hostname = 'www.youtube.com'; + } + + const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP'); + + requestUrl.hash = ''; + + fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { + res.json().then(info => { + if (info.url == null) return; + this.title = info.title; + this.description = info.description; + this.thumbnail = info.thumbnail; + this.icon = info.icon; + this.sitename = info.sitename; + this.fetching = false; + this.player = info.player; + }) + }); + + (window as any).addEventListener('message', this.adjustTweetHeight); + }, + + mounted() { + // 300pxないと絶対右にはみ出るので左に移動してしまう + const areaWidth = (this.$el as any)?.clientWidth; + if (areaWidth && areaWidth < 300) this.tweetLeft = areaWidth - 241; + }, + + methods: { + adjustTweetHeight(message: any) { + if (message.origin !== 'https://platform.twitter.com') return; + const embed = message.data?.['twttr.embed']; + if (embed?.method !== 'twttr.private.resize') return; + if (embed?.id !== this.embedId) return; + const height = embed?.params[0]?.height; + if (height) this.tweetHeight = height; + }, + }, + + beforeUnmount() { + (window as any).removeEventListener('message', this.adjustTweetHeight); + }, +}); +</script> + +<style lang="scss" scoped> +.player { + position: relative; + width: 100%; + + > button { + position: absolute; + top: -1.5em; + right: 0; + font-size: 1em; + width: 1.5em; + height: 1.5em; + padding: 0; + margin: 0; + color: var(--fg); + background: rgba(128, 128, 128, 0.2); + opacity: 0.7; + + &:hover { + opacity: 0.9; + } + } + + > iframe { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + } +} + +.mk-url-preview { + &.max-width_400px { + > a { + font-size: 12px; + + > .thumbnail { + height: 80px; + } + + > article { + padding: 12px; + } + } + } + + &.max-width_350px { + > a { + font-size: 10px; + + > .thumbnail { + height: 70px; + } + + > article { + padding: 8px; + + > header { + margin-bottom: 4px; + } + + > footer { + margin-top: 4px; + + > img { + width: 12px; + height: 12px; + } + } + } + + &.compact { + > .thumbnail { + position: absolute; + width: 56px; + height: 100%; + } + + > article { + left: 56px; + width: calc(100% - 56px); + padding: 4px; + + > header { + margin-bottom: 2px; + } + + > footer { + margin-top: 2px; + } + } + } + } + } + + > a { + position: relative; + display: block; + font-size: 14px; + box-shadow: 0 0 0 1px var(--divider); + border-radius: 8px; + overflow: hidden; + + &:hover { + text-decoration: none; + border-color: rgba(0, 0, 0, 0.2); + + > article > header > h1 { + text-decoration: underline; + } + } + + > .thumbnail { + position: absolute; + width: 100px; + height: 100%; + background-position: center; + background-size: cover; + display: flex; + justify-content: center; + align-items: center; + + > button { + font-size: 3.5em; + opacity: 0.7; + + &:hover { + font-size: 4em; + opacity: 0.9; + } + } + + & + article { + left: 100px; + width: calc(100% - 100px); + } + } + + > article { + position: relative; + box-sizing: border-box; + padding: 16px; + + > header { + margin-bottom: 8px; + + > h1 { + margin: 0; + font-size: 1em; + } + } + + > p { + margin: 0; + font-size: 0.8em; + } + + > footer { + margin-top: 8px; + height: 16px; + + > img { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 4px; + vertical-align: top; + } + + > p { + display: inline-block; + margin: 0; + color: var(--urlPreviewInfo); + font-size: 0.8em; + line-height: 16px; + vertical-align: top; + } + } + } + + &.compact { + > article { + > header h1, p, footer { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + } +} +</style> diff --git a/packages/client/src/components/user-info.vue b/packages/client/src/components/user-info.vue new file mode 100644 index 0000000000..ce82443b84 --- /dev/null +++ b/packages/client/src/components/user-info.vue @@ -0,0 +1,142 @@ +<template> +<div class="_panel vjnjpkug"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> + <p class="username"><MkAcct :user="user"/></p> + </div> + <div class="description"> + <div class="mfm" v-if="user.description"> + <Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> + </div> + <span v-else style="opacity: 0.7;">{{ $ts.noAccountDescription }}</span> + </div> + <div class="status"> + <div> + <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> + </div> + </div> + <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkFollowButton from './follow-button.vue'; +import { userPage } from '@/filters/user'; + +export default defineComponent({ + components: { + MkFollowButton + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + }; + }, + + methods: { + userPage, + } +}); +</script> + +<style lang="scss" scoped> +.vjnjpkug { + position: relative; + + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 4px var(--panel); + } + + > .title { + display: block; + padding: 10px 0 10px 88px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + opacity: 0.7; + } + } + + > .description { + padding: 16px; + font-size: 0.8em; + border-top: solid 0.5px var(--divider); + + > .mfm { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + + > .status { + padding: 10px 16px; + border-top: solid 0.5px var(--divider); + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--fg); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/packages/client/src/components/user-list.vue b/packages/client/src/components/user-list.vue new file mode 100644 index 0000000000..733dbe0ad7 --- /dev/null +++ b/packages/client/src/components/user-list.vue @@ -0,0 +1,91 @@ +<template> +<MkError v-if="error" @retry="init()"/> + +<div v-else class="efvhhmdq _isolated"> + <div class="no-users" v-if="empty"> + <p>{{ $ts.noUsers }}</p> + </div> + <div class="users"> + <MkUserInfo class="user" v-for="user in users" :user="user" :key="user.id"/> + </div> + <button class="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :class="{ fetching: moreFetching }" v-show="more" :disabled="moreFetching"> + <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }} + </button> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import MkUserInfo from './user-info.vue'; +import { userPage } from '@/filters/user'; + +export default defineComponent({ + components: { + MkUserInfo, + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + extract: { + required: false + }, + expanded: { + type: Boolean, + default: true + }, + }, + + computed: { + users() { + return this.extract ? this.extract(this.items) : this.items; + } + }, + + methods: { + userPage + } +}); +</script> + +<style lang="scss" scoped> +.efvhhmdq { + > .no-users { + text-align: center; + } + + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + } + + > .more { + display: block; + width: 100%; + padding: 16px; + + &:hover { + background: rgba(#000, 0.025); + } + + &:active { + background: rgba(#000, 0.05); + } + + &.fetching { + cursor: wait; + } + + > i { + margin-right: 4px; + } + } +} +</style> diff --git a/packages/client/src/components/user-online-indicator.vue b/packages/client/src/components/user-online-indicator.vue new file mode 100644 index 0000000000..afaf0e8736 --- /dev/null +++ b/packages/client/src/components/user-online-indicator.vue @@ -0,0 +1,50 @@ +<template> +<div class="fzgwjkgc" :class="user.onlineStatus" v-tooltip="text"></div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + user: { + type: Object, + required: true + }, + }, + + computed: { + text(): string { + switch (this.user.onlineStatus) { + case 'online': return this.$ts.online; + case 'active': return this.$ts.active; + case 'offline': return this.$ts.offline; + case 'unknown': return this.$ts.unknown; + } + } + } +}); +</script> + +<style lang="scss" scoped> +.fzgwjkgc { + box-shadow: 0 0 0 3px var(--panel); + border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる + + &.online { + background: #58d4c9; + } + + &.active { + background: #e4bc48; + } + + &.offline { + background: #ea5353; + } + + &.unknown { + background: #888; + } +} +</style> diff --git a/packages/client/src/components/user-preview.vue b/packages/client/src/components/user-preview.vue new file mode 100644 index 0000000000..f7fd3f6b64 --- /dev/null +++ b/packages/client/src/components/user-preview.vue @@ -0,0 +1,192 @@ +<template> +<transition name="popup" appear @after-leave="$emit('closed')"> + <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> + <div v-if="fetched" class="info"> + <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> + <p class="username"><MkAcct :user="user"/></p> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> + </div> + <div class="status"> + <div> + <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> + </div> + <div> + <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> + </div> + <div> + <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> + </div> + </div> + <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/> + </div> + <div v-else> + <MkLoading/> + </div> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkFollowButton from './follow-button.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkFollowButton + }, + + props: { + showing: { + type: Boolean, + required: true + }, + q: { + type: String, + required: true + }, + source: { + required: true + } + }, + + emits: ['closed', 'mouseover', 'mouseleave'], + + data() { + return { + user: null, + fetched: false, + top: 0, + left: 0, + }; + }, + + mounted() { + if (typeof this.q == 'object') { + this.user = this.q; + this.fetched = true; + } else { + const query = this.q.startsWith('@') ? + Acct.parse(this.q.substr(1)) : + { userId: this.q }; + + os.api('users/show', query).then(user => { + if (!this.showing) return; + this.user = user; + this.fetched = true; + }); + } + + const rect = this.source.getBoundingClientRect(); + const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; + const y = rect.top + this.source.offsetHeight + window.pageYOffset; + + this.top = y; + this.left = x; + }, + + methods: { + userPage + } +}); +</script> + +<style lang="scss" scoped> +.popup-enter-active, .popup-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.popup-enter-from, .popup-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.fxxzrfni { + position: absolute; + z-index: 11000; + width: 300px; + overflow: hidden; + transform-origin: center top; + + > .info { + > .banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; + } + + > .avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 3px var(--face); + border-radius: 8px; + } + + > .title { + display: block; + padding: 8px 0 8px 82px; + + > .name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; + } + + > .username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + opacity: 0.7; + } + } + + > .description { + padding: 0 16px; + font-size: 0.8em; + color: var(--fg); + } + + > .status { + padding: 8px 16px; + + > div { + display: inline-block; + width: 33%; + + > p { + margin: 0; + font-size: 0.7em; + color: var(--fg); + } + + > span { + font-size: 1em; + color: var(--accent); + } + } + } + + > .koudoku-button { + position: absolute; + top: 8px; + right: 8px; + } + } +} +</style> diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/user-select-dialog.vue new file mode 100644 index 0000000000..80f6293563 --- /dev/null +++ b/packages/client/src/components/user-select-dialog.vue @@ -0,0 +1,199 @@ +<template> +<XModalWindow ref="dialog" + :with-ok-button="true" + :ok-button-disabled="selected == null" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="$emit('closed')" +> + <template #header>{{ $ts.selectUser }}</template> + <div class="tbhwbxda _monolithic_"> + <div class="_section"> + <div class="_inputSplit"> + <MkInput v-model="username" class="input" @update:modelValue="search" ref="username"> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + </MkInput> + <MkInput v-model="host" class="input" @update:modelValue="search"> + <template #label>{{ $ts.host }}</template> + <template #prefix>@</template> + </MkInput> + </div> + </div> + <div class="_section result" v-if="username != '' || host != ''" :class="{ hit: users.length > 0 }"> + <div class="users" v-if="users.length > 0"> + <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + </div> + </div> + <div v-else class="empty"> + <span>{{ $ts.noUsers }}</span> + </div> + </div> + <div class="_section recent" v-if="username == '' && host == ''"> + <div class="users"> + <div class="user" v-for="user in recentUsers" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + </div> + </div> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkInput from './form/input.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkInput, + XModalWindow, + }, + + props: { + }, + + emits: ['ok', 'cancel', 'closed'], + + data() { + return { + username: '', + host: '', + recentUsers: [], + users: [], + selected: null, + }; + }, + + async mounted() { + this.focus(); + + this.$nextTick(() => { + this.focus(); + }); + + this.recentUsers = await os.api('users/show', { + userIds: this.$store.state.recentlyUsedUsers + }); + }, + + methods: { + search() { + if (this.username == '' && this.host == '') { + this.users = []; + return; + } + os.api('users/search-by-username-and-host', { + username: this.username, + host: this.host, + limit: 10, + detail: false + }).then(users => { + this.users = users; + }); + }, + + focus() { + this.$refs.username.focus(); + }, + + ok() { + this.$emit('ok', this.selected); + this.$refs.dialog.close(); + + // 最近使ったユーザー更新 + let recents = this.$store.state.recentlyUsedUsers; + recents = recents.filter(x => x !== this.selected.id); + recents.unshift(this.selected.id); + this.$store.set('recentlyUsedUsers', recents.splice(0, 16)); + }, + + cancel() { + this.$emit('cancel'); + this.$refs.dialog.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.tbhwbxda { + > ._section { + display: flex; + flex-direction: column; + overflow: auto; + height: 100%; + + &.result.hit { + padding: 0; + } + + &.recent { + padding: 0; + } + + > .users { + flex: 1; + overflow: auto; + padding: 8px 0; + + > .user { + display: flex; + align-items: center; + padding: 8px var(--root-margin); + font-size: 14px; + + &:hover { + background: var(--X7); + } + + &.selected { + background: var(--accent); + color: #fff; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + min-width: 0; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + + > .empty { + opacity: 0.7; + text-align: center; + } + } +} +</style> diff --git a/packages/client/src/components/users-dialog.vue b/packages/client/src/components/users-dialog.vue new file mode 100644 index 0000000000..6eec5289b3 --- /dev/null +++ b/packages/client/src/components/users-dialog.vue @@ -0,0 +1,147 @@ +<template> +<div class="mk-users-dialog"> + <div class="header"> + <span>{{ title }}</span> + <button class="_button" @click="close()"><i class="fas fa-times"></i></button> + </div> + + <div class="users"> + <MkA v-for="item in items" class="user" :key="item.id" :to="userPage(extract ? extract(item) : item)"> + <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="extract ? extract(item) : item" class="name"/> + <MkAcct :user="extract ? extract(item) : item" class="acct"/> + </div> + </MkA> + </div> + <button class="more _button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching"> + <template v-if="!moreFetching">{{ $ts.loadMore }}</template> + <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> + </button> + + <p class="empty" v-if="empty">{{ $ts.noUsers }}</p> + + <MkError v-if="error" @retry="init()"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import { userPage } from '@/filters/user'; + +export default defineComponent({ + mixins: [ + paging({}), + ], + + props: { + title: { + required: true + }, + pagination: { + required: true + }, + extract: { + required: false + } + }, + + data() { + return { + }; + }, + + methods: { + userPage + } +}); +</script> + +<style lang="scss" scoped> +.mk-users-dialog { + width: 350px; + height: 350px; + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + + > .header { + display: flex; + flex-shrink: 0; + + > button { + height: 58px; + width: 58px; + + @media (max-width: 500px) { + height: 42px; + width: 42px; + } + } + + > span { + flex: 1; + line-height: 58px; + padding-left: 32px; + font-weight: bold; + + @media (max-width: 500px) { + line-height: 42px; + padding-left: 16px; + } + } + } + + > .users { + flex: 1; + overflow: auto; + + &:empty { + display: none; + } + + > .user { + display: flex; + align-items: center; + font-size: 14px; + padding: 8px 32px; + + @media (max-width: 500px) { + padding: 8px 16px; + } + + > * { + pointer-events: none; + user-select: none; + } + + > .avatar { + width: 45px; + height: 45px; + } + + > .body { + padding: 0 8px; + overflow: hidden; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + + > .empty { + text-align: center; + opacity: 0.5; + } +} +</style> diff --git a/packages/client/src/components/visibility-picker.vue b/packages/client/src/components/visibility-picker.vue new file mode 100644 index 0000000000..7a811b42f7 --- /dev/null +++ b/packages/client/src/components/visibility-picker.vue @@ -0,0 +1,167 @@ +<template> +<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> + <div class="gqyayizv _popup"> + <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="1" key="public"> + <div><i class="fas fa-globe"></i></div> + <div> + <span>{{ $ts._visibility.public }}</span> + <span>{{ $ts._visibility.publicDescription }}</span> + </div> + </button> + <button class="_button" @click="choose('home')" :class="{ active: v == 'home' }" data-index="2" key="home"> + <div><i class="fas fa-home"></i></div> + <div> + <span>{{ $ts._visibility.home }}</span> + <span>{{ $ts._visibility.homeDescription }}</span> + </div> + </button> + <button class="_button" @click="choose('followers')" :class="{ active: v == 'followers' }" data-index="3" key="followers"> + <div><i class="fas fa-unlock"></i></div> + <div> + <span>{{ $ts._visibility.followers }}</span> + <span>{{ $ts._visibility.followersDescription }}</span> + </div> + </button> + <button :disabled="localOnly" class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="4" key="specified"> + <div><i class="fas fa-envelope"></i></div> + <div> + <span>{{ $ts._visibility.specified }}</span> + <span>{{ $ts._visibility.specifiedDescription }}</span> + </div> + </button> + <div class="divider"></div> + <button class="_button localOnly" @click="localOnly = !localOnly" :class="{ active: localOnly }" data-index="5" key="localOnly"> + <div><i class="fas fa-biohazard"></i></div> + <div> + <span>{{ $ts._visibility.localOnly }}</span> + <span>{{ $ts._visibility.localOnlyDescription }}</span> + </div> + <div><i :class="localOnly ? 'fas fa-toggle-on' : 'fas fa-toggle-off'"></i></div> + </button> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; + +export default defineComponent({ + components: { + MkModal, + }, + props: { + currentVisibility: { + type: String, + required: true + }, + currentLocalOnly: { + type: Boolean, + required: true + }, + src: { + required: false + }, + }, + emits: ['change-visibility', 'change-local-only', 'closed'], + data() { + return { + v: this.currentVisibility, + localOnly: this.currentLocalOnly, + } + }, + watch: { + localOnly() { + this.$emit('change-local-only', this.localOnly); + } + }, + methods: { + choose(visibility) { + this.v = visibility; + this.$emit('change-visibility', visibility); + this.$nextTick(() => { + this.$refs.modal.close(); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.gqyayizv { + width: 240px; + padding: 8px 0; + + > .divider { + margin: 8px 0; + border-top: solid 0.5px var(--divider); + } + + > button { + display: flex; + padding: 8px 14px; + font-size: 12px; + text-align: left; + width: 100%; + box-sizing: border-box; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:active { + background: rgba(0, 0, 0, 0.1); + } + + &.active { + color: #fff; + background: var(--accent); + } + + &.localOnly.active { + color: var(--accent); + background: inherit; + } + + > *:nth-child(1) { + display: flex; + justify-content: center; + align-items: center; + margin-right: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + + > *:nth-child(2) { + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > span:first-child { + display: block; + font-weight: bold; + } + + > span:last-child:not(:first-child) { + opacity: 0.6; + } + } + + > *:nth-child(3) { + display: flex; + justify-content: center; + align-items: center; + margin-left: 10px; + width: 16px; + top: 0; + bottom: 0; + margin-top: auto; + margin-bottom: auto; + } + } +} +</style> diff --git a/packages/client/src/components/waiting-dialog.vue b/packages/client/src/components/waiting-dialog.vue new file mode 100644 index 0000000000..35a760ea41 --- /dev/null +++ b/packages/client/src/components/waiting-dialog.vue @@ -0,0 +1,92 @@ +<template> +<MkModal ref="modal" @click="success ? done() : () => {}" @closed="$emit('closed')"> + <div class="iuyakobc" :class="{ iconOnly: (text == null) || success }"> + <i v-if="success" class="fas fa-check icon success"></i> + <i v-else class="fas fa-spinner fa-pulse icon waiting"></i> + <div class="text" v-if="text && !success">{{ text }}<MkEllipsis/></div> + </div> +</MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@/components/ui/modal.vue'; + +export default defineComponent({ + components: { + MkModal, + }, + + props: { + success: { + type: Boolean, + required: true, + }, + showing: { + type: Boolean, + required: true, + }, + text: { + type: String, + required: false, + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + }; + }, + + watch: { + showing() { + if (!this.showing) this.done(); + } + }, + + methods: { + done() { + this.$emit('done'); + this.$refs.modal.close(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.iuyakobc { + position: relative; + padding: 32px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + width: 250px; + + &.iconOnly { + padding: 0; + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; + } + + > .icon { + font-size: 32px; + + &.success { + color: var(--accent); + } + + &.waiting { + opacity: 0.7; + } + } + + > .text { + margin-top: 16px; + } +} +</style> diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue new file mode 100644 index 0000000000..8aec77796d --- /dev/null +++ b/packages/client/src/components/widgets.vue @@ -0,0 +1,152 @@ +<template> +<div class="vjoppmmu"> + <template v-if="edit"> + <header> + <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)"> + <template #label>{{ $ts.selectWidget }}</template> + <option v-for="widget in widgetDefs" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option> + </MkSelect> + <MkButton inline @click="addWidget" primary><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton> + </header> + <XDraggable + v-model="_widgets" + item-key="id" + animation="150" + > + <template #item="{element}"> + <div class="customize-container"> + <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button> + <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button> + <component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" @updateProps="updateWidget(element.id, $event)"/> + </div> + </template> + </XDraggable> + </template> + <component v-else class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" @updateProps="updateWidget(widget.id, $event)"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import MkSelect from '@/components/form/select.vue'; +import MkButton from '@/components/ui/button.vue'; +import { widgets as widgetDefs } from '@/widgets'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + MkSelect, + MkButton, + }, + + props: { + widgets: { + type: Array, + required: true, + }, + edit: { + type: Boolean, + required: true, + }, + }, + + emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'], + + data() { + return { + widgetAdderSelected: null, + widgetDefs, + settings: {}, + }; + }, + + computed: { + _widgets: { + get() { + return this.widgets; + }, + set(value) { + this.$emit('updateWidgets', value); + } + } + }, + + methods: { + configWidget(id) { + this.settings[id](); + }, + + addWidget() { + if (this.widgetAdderSelected == null) return; + + this.$emit('addWidget', { + name: this.widgetAdderSelected, + id: uuid(), + data: {} + }); + + this.widgetAdderSelected = null; + }, + + removeWidget(widget) { + this.$emit('removeWidget', widget); + }, + + updateWidget(id, data) { + this.$emit('updateWidget', { id, data }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.vjoppmmu { + > header { + margin: 16px 0; + + > * { + width: 100%; + padding: 4px; + } + } + + > .widget, .customize-container { + margin: var(--margin) 0; + + &:first-of-type { + margin-top: 0; + } + } + + .customize-container { + position: relative; + cursor: move; + + > *:not(.remove):not(.config) { + pointer-events: none; + } + + > .config, + > .remove { + position: absolute; + z-index: 10000; + top: 8px; + width: 32px; + height: 32px; + color: #fff; + background: rgba(#000, 0.7); + border-radius: 4px; + } + + > .config { + right: 8px + 8px + 32px; + } + + > .remove { + right: 8px; + } + } +} +</style> diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts new file mode 100644 index 0000000000..f2022b0f02 --- /dev/null +++ b/packages/client/src/config.ts @@ -0,0 +1,15 @@ +const address = new URL(location.href); +const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content; + +export const host = address.host; +export const hostname = address.hostname; +export const url = address.origin; +export const apiUrl = url + '/api'; +export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming'; +export const lang = localStorage.getItem('lang'); +export const langs = _LANGS_; +export const locale = JSON.parse(localStorage.getItem('locale')); +export const version = _VERSION_; +export const instanceName = siteName === 'Misskey' ? host : siteName; +export const ui = localStorage.getItem('ui'); +export const debug = localStorage.getItem('debug') === 'true'; diff --git a/packages/client/src/directives/anim.ts b/packages/client/src/directives/anim.ts new file mode 100644 index 0000000000..1ceef984d8 --- /dev/null +++ b/packages/client/src/directives/anim.ts @@ -0,0 +1,18 @@ +import { Directive } from 'vue'; + +export default { + beforeMount(src, binding, vn) { + src.style.opacity = '0'; + src.style.transform = 'scale(0.9)'; + // ページネーションと相性が悪いので + //if (typeof binding.value === 'number') src.style.transitionDelay = `${binding.value * 30}ms`; + src.classList.add('_zoom'); + }, + + mounted(src, binding, vn) { + setTimeout(() => { + src.style.opacity = '1'; + src.style.transform = 'none'; + }, 1); + }, +} as Directive; diff --git a/packages/client/src/directives/appear.ts b/packages/client/src/directives/appear.ts new file mode 100644 index 0000000000..a504d11ef9 --- /dev/null +++ b/packages/client/src/directives/appear.ts @@ -0,0 +1,22 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const fn = binding.value; + if (fn == null) return; + + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + fn(); + } + }); + + observer.observe(src); + + src._observer_ = observer; + }, + + unmounted(src, binding, vn) { + if (src._observer_) src._observer_.disconnect(); + } +} as Directive; diff --git a/packages/client/src/directives/click-anime.ts b/packages/client/src/directives/click-anime.ts new file mode 100644 index 0000000000..001dcca46e --- /dev/null +++ b/packages/client/src/directives/click-anime.ts @@ -0,0 +1,29 @@ +import { Directive } from 'vue'; +import { defaultStore } from '@/store'; + +export default { + mounted(el, binding, vn) { + if (!defaultStore.state.animation) return; + + el.classList.add('_anime_bounce_standBy'); + + el.addEventListener('mousedown', () => { + el.classList.add('_anime_bounce_standBy'); + el.classList.add('_anime_bounce_ready'); + + el.addEventListener('mouseleave', () => { + el.classList.remove('_anime_bounce_ready'); + }); + }); + + el.addEventListener('click', () => { + el.classList.add('_anime_bounce'); + }); + + el.addEventListener('animationend', () => { + el.classList.remove('_anime_bounce_ready'); + el.classList.remove('_anime_bounce'); + el.classList.add('_anime_bounce_standBy'); + }); + } +} as Directive; diff --git a/packages/client/src/directives/follow-append.ts b/packages/client/src/directives/follow-append.ts new file mode 100644 index 0000000000..b0e99628b0 --- /dev/null +++ b/packages/client/src/directives/follow-append.ts @@ -0,0 +1,35 @@ +import { Directive } from 'vue'; +import { getScrollContainer, getScrollPosition } from '@/scripts/scroll'; + +export default { + mounted(src, binding, vn) { + if (binding.value === false) return; + + let isBottom = true; + + const container = getScrollContainer(src)!; + container.addEventListener('scroll', () => { + const pos = getScrollPosition(container); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + isBottom = (pos + viewHeight > height - 32); + }, { passive: true }); + container.scrollTop = container.scrollHeight; + + const ro = new ResizeObserver((entries, observer) => { + if (isBottom) { + const height = container.scrollHeight; + container.scrollTop = height; + } + }); + + ro.observe(src); + + // TODO: 新たにプロパティを作るのをやめMapを使う + src._ro_ = ro; + }, + + unmounted(src, binding, vn) { + if (src._ro_) src._ro_.unobserve(src); + } +} as Directive; diff --git a/packages/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts new file mode 100644 index 0000000000..e3b5dea0f3 --- /dev/null +++ b/packages/client/src/directives/get-size.ts @@ -0,0 +1,34 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const calc = () => { + const height = src.clientHeight; + const width = src.clientWidth; + + // 要素が(一時的に)DOMに存在しないときは計算スキップ + if (height === 0) return; + + binding.value(width, height); + }; + + calc(); + + // Vue3では使えなくなった + // 無くても大丈夫か...? + // TODO: ↑大丈夫じゃなかったので解決策を探す + //vn.context.$on('hook:activated', calc); + + const ro = new ResizeObserver((entries, observer) => { + calc(); + }); + ro.observe(src); + + src._get_size_ro_ = ro; + }, + + unmounted(src, binding, vn) { + binding.value(0, 0); + src._get_size_ro_.unobserve(src); + } +} as Directive; diff --git a/packages/client/src/directives/hotkey.ts b/packages/client/src/directives/hotkey.ts new file mode 100644 index 0000000000..d813a95074 --- /dev/null +++ b/packages/client/src/directives/hotkey.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; +import { makeHotkey } from '../scripts/hotkey'; + +export default { + mounted(el, binding) { + el._hotkey_global = binding.modifiers.global === true; + + el._keyHandler = makeHotkey(binding.value); + + if (el._hotkey_global) { + document.addEventListener('keydown', el._keyHandler); + } else { + el.addEventListener('keydown', el._keyHandler); + } + }, + + unmounted(el) { + if (el._hotkey_global) { + document.removeEventListener('keydown', el._keyHandler); + } else { + el.removeEventListener('keydown', el._keyHandler); + } + } +} as Directive; diff --git a/packages/client/src/directives/index.ts b/packages/client/src/directives/index.ts new file mode 100644 index 0000000000..cd71bc26d3 --- /dev/null +++ b/packages/client/src/directives/index.ts @@ -0,0 +1,26 @@ +import { App } from 'vue'; + +import userPreview from './user-preview'; +import size from './size'; +import getSize from './get-size'; +import particle from './particle'; +import tooltip from './tooltip'; +import hotkey from './hotkey'; +import appear from './appear'; +import anim from './anim'; +import stickyContainer from './sticky-container'; +import clickAnime from './click-anime'; + +export default function(app: App) { + app.directive('userPreview', userPreview); + app.directive('user-preview', userPreview); + app.directive('size', size); + app.directive('get-size', getSize); + app.directive('particle', particle); + app.directive('tooltip', tooltip); + app.directive('hotkey', hotkey); + app.directive('appear', appear); + app.directive('anim', anim); + app.directive('click-anime', clickAnime); + app.directive('sticky-container', stickyContainer); +} diff --git a/packages/client/src/directives/particle.ts b/packages/client/src/directives/particle.ts new file mode 100644 index 0000000000..c90df89a5e --- /dev/null +++ b/packages/client/src/directives/particle.ts @@ -0,0 +1,18 @@ +import Particle from '@/components/particle.vue'; +import { popup } from '@/os'; + +export default { + mounted(el, binding, vn) { + // 明示的に false であればバインドしない + if (binding.value === false) return; + + el.addEventListener('click', () => { + const rect = el.getBoundingClientRect(); + + const x = rect.left + (el.clientWidth / 2); + const y = rect.top + (el.clientHeight / 2); + + popup(Particle, { x, y }, {}, 'end'); + }); + } +}; diff --git a/packages/client/src/directives/size.ts b/packages/client/src/directives/size.ts new file mode 100644 index 0000000000..a72a97abcc --- /dev/null +++ b/packages/client/src/directives/size.ts @@ -0,0 +1,68 @@ +import { Directive } from 'vue'; + +//const observers = new Map<Element, ResizeObserver>(); + +export default { + mounted(src, binding, vn) { + const query = binding.value; + + const addClass = (el: Element, cls: string) => { + el.classList.add(cls); + }; + + const removeClass = (el: Element, cls: string) => { + el.classList.remove(cls); + }; + + const calc = () => { + const width = src.clientWidth; + + // 要素が(一時的に)DOMに存在しないときは計算スキップ + if (width === 0) return; + + if (query.max) { + for (const v of query.max) { + if (width <= v) { + addClass(src, 'max-width_' + v + 'px'); + } else { + removeClass(src, 'max-width_' + v + 'px'); + } + } + } + if (query.min) { + for (const v of query.min) { + if (width >= v) { + addClass(src, 'min-width_' + v + 'px'); + } else { + removeClass(src, 'min-width_' + v + 'px'); + } + } + } + }; + + calc(); + + window.addEventListener('resize', calc); + + // Vue3では使えなくなった + // 無くても大丈夫か...? + // TODO: ↑大丈夫じゃなかったので解決策を探す + //vn.context.$on('hook:activated', calc); + + //const ro = new ResizeObserver((entries, observer) => { + // calc(); + //}); + + //ro.observe(el); + + // TODO: 新たにプロパティを作るのをやめMapを使う + // ただメモリ的には↓の方が省メモリかもしれないので検討中 + //el._ro_ = ro; + src._calc_ = calc; + }, + + unmounted(src, binding, vn) { + //el._ro_.unobserve(el); + window.removeEventListener('resize', src._calc_); + } +} as Directive; diff --git a/packages/client/src/directives/sticky-container.ts b/packages/client/src/directives/sticky-container.ts new file mode 100644 index 0000000000..9610eba4da --- /dev/null +++ b/packages/client/src/directives/sticky-container.ts @@ -0,0 +1,15 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + //const query = binding.value; + + const header = src.children[0]; + const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px'; + src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); + header.style.setProperty('--stickyTop', currentStickyTop); + header.style.position = 'sticky'; + header.style.top = 'var(--stickyTop)'; + header.style.zIndex = '1'; + }, +} as Directive; diff --git a/packages/client/src/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts new file mode 100644 index 0000000000..1294f6b063 --- /dev/null +++ b/packages/client/src/directives/tooltip.ts @@ -0,0 +1,87 @@ +import { Directive, ref } from 'vue'; +import { isDeviceTouch } from '@/scripts/is-device-touch'; +import { popup, dialog } from '@/os'; + +const start = isDeviceTouch ? 'touchstart' : 'mouseover'; +const end = isDeviceTouch ? 'touchend' : 'mouseleave'; +const delay = 100; + +export default { + mounted(el: HTMLElement, binding, vn) { + const self = (el as any)._tooltipDirective_ = {} as any; + + self.text = binding.value as string; + self._close = null; + self.showTimer = null; + self.hideTimer = null; + self.checkTimer = null; + + self.close = () => { + if (self._close) { + clearInterval(self.checkTimer); + self._close(); + self._close = null; + } + }; + + if (binding.arg === 'dialog') { + el.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + dialog({ + type: 'info', + text: binding.value, + }); + return false; + }); + } + + self.show = () => { + if (!document.body.contains(el)) return; + if (self._close) return; + if (self.text == null) return; + + const showing = ref(true); + popup(import('@/components/ui/tooltip.vue'), { + showing, + text: self.text, + source: el + }, {}, 'closed'); + + self._close = () => { + showing.value = false; + }; + }; + + el.addEventListener('selectstart', e => { + e.preventDefault(); + }); + + el.addEventListener(start, () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.showTimer = setTimeout(self.show, delay); + }, { passive: true }); + + el.addEventListener(end, () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.hideTimer = setTimeout(self.close, delay); + }, { passive: true }); + + el.addEventListener('click', () => { + clearTimeout(self.showTimer); + self.close(); + }); + }, + + updated(el, binding) { + const self = el._tooltipDirective_; + self.text = binding.value as string; + }, + + unmounted(el, binding, vn) { + const self = el._tooltipDirective_; + clearInterval(self.checkTimer); + }, +} as Directive; diff --git a/packages/client/src/directives/user-preview.ts b/packages/client/src/directives/user-preview.ts new file mode 100644 index 0000000000..68d9e2816c --- /dev/null +++ b/packages/client/src/directives/user-preview.ts @@ -0,0 +1,118 @@ +import { Directive, ref } from 'vue'; +import autobind from 'autobind-decorator'; +import { popup } from '@/os'; + +export class UserPreview { + private el; + private user; + private showTimer; + private hideTimer; + private checkTimer; + private promise; + + constructor(el, user) { + this.el = el; + this.user = user; + + this.attach(); + } + + @autobind + private show() { + if (!document.body.contains(this.el)) return; + if (this.promise) return; + + const showing = ref(true); + + popup(import('@/components/user-preview.vue'), { + showing, + q: this.user, + source: this.el + }, { + mouseover: () => { + clearTimeout(this.hideTimer); + }, + mouseleave: () => { + clearTimeout(this.showTimer); + this.hideTimer = setTimeout(this.close, 500); + }, + }, 'closed'); + + this.promise = { + cancel: () => { + showing.value = false; + } + }; + + this.checkTimer = setInterval(() => { + if (!document.body.contains(this.el)) { + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.close(); + } + }, 1000); + } + + @autobind + private close() { + if (this.promise) { + clearInterval(this.checkTimer); + this.promise.cancel(); + this.promise = null; + } + } + + @autobind + private onMouseover() { + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.showTimer = setTimeout(this.show, 500); + } + + @autobind + private onMouseleave() { + clearTimeout(this.showTimer); + clearTimeout(this.hideTimer); + this.hideTimer = setTimeout(this.close, 500); + } + + @autobind + private onClick() { + clearTimeout(this.showTimer); + this.close(); + } + + @autobind + public attach() { + this.el.addEventListener('mouseover', this.onMouseover); + this.el.addEventListener('mouseleave', this.onMouseleave); + this.el.addEventListener('click', this.onClick); + } + + @autobind + public detach() { + this.el.removeEventListener('mouseover', this.onMouseover); + this.el.removeEventListener('mouseleave', this.onMouseleave); + this.el.removeEventListener('click', this.onClick); + clearInterval(this.checkTimer); + } +} + +export default { + mounted(el: HTMLElement, binding, vn) { + if (binding.value == null) return; + + // TODO: 新たにプロパティを作るのをやめMapを使う + // ただメモリ的には↓の方が省メモリかもしれないので検討中 + const self = (el as any)._userPreviewDirective_ = {} as any; + + self.preview = new UserPreview(el, binding.value); + }, + + unmounted(el, binding, vn) { + if (binding.value == null) return; + + const self = el._userPreviewDirective_; + self.preview.detach(); + } +} as Directive; diff --git a/packages/client/src/emojilist.json b/packages/client/src/emojilist.json new file mode 100644 index 0000000000..75c424ab4b --- /dev/null +++ b/packages/client/src/emojilist.json @@ -0,0 +1,1749 @@ +[ + { "category": "face", "char": "😀", "name": "grinning", "keywords": ["face", "smile", "happy", "joy", ": D", "grin"] }, + { "category": "face", "char": "😬", "name": "grimacing", "keywords": ["face", "grimace", "teeth"] }, + { "category": "face", "char": "😁", "name": "grin", "keywords": ["face", "happy", "smile", "joy", "kawaii"] }, + { "category": "face", "char": "😂", "name": "joy", "keywords": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"] }, + { "category": "face", "char": "🤣", "name": "rofl", "keywords": ["face", "rolling", "floor", "laughing", "lol", "haha"] }, + { "category": "face", "char": "🥳", "name": "partying", "keywords": ["face", "celebration", "woohoo"] }, + { "category": "face", "char": "😃", "name": "smiley", "keywords": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"] }, + { "category": "face", "char": "😄", "name": "smile", "keywords": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"] }, + { "category": "face", "char": "😅", "name": "sweat_smile", "keywords": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"] }, + { "category": "face", "char": "🥲", "name": "smiling_face_with_tear", "keywords": ["face"] }, + { "category": "face", "char": "😆", "name": "laughing", "keywords": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"] }, + { "category": "face", "char": "😇", "name": "innocent", "keywords": ["face", "angel", "heaven", "halo"] }, + { "category": "face", "char": "😉", "name": "wink", "keywords": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"] }, + { "category": "face", "char": "😊", "name": "blush", "keywords": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"] }, + { "category": "face", "char": "🙂", "name": "slightly_smiling_face", "keywords": ["face", "smile"] }, + { "category": "face", "char": "🙃", "name": "upside_down_face", "keywords": ["face", "flipped", "silly", "smile"] }, + { "category": "face", "char": "☺️", "name": "relaxed", "keywords": ["face", "blush", "massage", "happiness"] }, + { "category": "face", "char": "😋", "name": "yum", "keywords": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"] }, + { "category": "face", "char": "😌", "name": "relieved", "keywords": ["face", "relaxed", "phew", "massage", "happiness"] }, + { "category": "face", "char": "😍", "name": "heart_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"] }, + { "category": "face", "char": "🥰", "name": "smiling_face_with_three_hearts", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"] }, + { "category": "face", "char": "😘", "name": "kissing_heart", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😗", "name": "kissing", "keywords": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😙", "name": "kissing_smiling_eyes", "keywords": ["face", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😚", "name": "kissing_closed_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😜", "name": "stuck_out_tongue_winking_eye", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"] }, + { "category": "face", "char": "🤪", "name": "zany", "keywords": ["face", "goofy", "crazy"] }, + { "category": "face", "char": "🤨", "name": "raised_eyebrow", "keywords": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"] }, + { "category": "face", "char": "🧐", "name": "monocle", "keywords": ["face", "stuffy", "wealthy"] }, + { "category": "face", "char": "😝", "name": "stuck_out_tongue_closed_eyes", "keywords": ["face", "prank", "playful", "mischievous", "smile", "tongue"] }, + { "category": "face", "char": "😛", "name": "stuck_out_tongue", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"] }, + { "category": "face", "char": "🤑", "name": "money_mouth_face", "keywords": ["face", "rich", "dollar", "money"] }, + { "category": "face", "char": "🤓", "name": "nerd_face", "keywords": ["face", "nerdy", "geek", "dork"] }, + { "category": "face", "char": "🥸", "name": "disguised_face", "keywords": ["face", "nose", "glasses", "incognito"] }, + { "category": "face", "char": "😎", "name": "sunglasses", "keywords": ["face", "cool", "smile", "summer", "beach", "sunglass"] }, + { "category": "face", "char": "🤩", "name": "star_struck", "keywords": ["face", "smile", "starry", "eyes", "grinning"] }, + { "category": "face", "char": "🤡", "name": "clown_face", "keywords": ["face"] }, + { "category": "face", "char": "🤠", "name": "cowboy_hat_face", "keywords": ["face", "cowgirl", "hat"] }, + { "category": "face", "char": "🤗", "name": "hugs", "keywords": ["face", "smile", "hug"] }, + { "category": "face", "char": "😏", "name": "smirk", "keywords": ["face", "smile", "mean", "prank", "smug", "sarcasm"] }, + { "category": "face", "char": "😶", "name": "no_mouth", "keywords": ["face", "hellokitty"] }, + { "category": "face", "char": "😐", "name": "neutral_face", "keywords": ["indifference", "meh", ": |", "neutral"] }, + { "category": "face", "char": "😑", "name": "expressionless", "keywords": ["face", "indifferent", "-_-", "meh", "deadpan"] }, + { "category": "face", "char": "😒", "name": "unamused", "keywords": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"] }, + { "category": "face", "char": "🙄", "name": "roll_eyes", "keywords": ["face", "eyeroll", "frustrated"] }, + { "category": "face", "char": "🤔", "name": "thinking", "keywords": ["face", "hmmm", "think", "consider"] }, + { "category": "face", "char": "🤥", "name": "lying_face", "keywords": ["face", "lie", "pinocchio"] }, + { "category": "face", "char": "🤭", "name": "hand_over_mouth", "keywords": ["face", "whoops", "shock", "surprise"] }, + { "category": "face", "char": "🤫", "name": "shushing", "keywords": ["face", "quiet", "shhh"] }, + { "category": "face", "char": "🤬", "name": "symbols_over_mouth", "keywords": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"] }, + { "category": "face", "char": "🤯", "name": "exploding_head", "keywords": ["face", "shocked", "mind", "blown"] }, + { "category": "face", "char": "😳", "name": "flushed", "keywords": ["face", "blush", "shy", "flattered"] }, + { "category": "face", "char": "😞", "name": "disappointed", "keywords": ["face", "sad", "upset", "depressed", ": ("] }, + { "category": "face", "char": "😟", "name": "worried", "keywords": ["face", "concern", "nervous", ": ("] }, + { "category": "face", "char": "😠", "name": "angry", "keywords": ["mad", "face", "annoyed", "frustrated"] }, + { "category": "face", "char": "😡", "name": "rage", "keywords": ["angry", "mad", "hate", "despise"] }, + { "category": "face", "char": "😔", "name": "pensive", "keywords": ["face", "sad", "depressed", "upset"] }, + { "category": "face", "char": "😕", "name": "confused", "keywords": ["face", "indifference", "huh", "weird", "hmmm", ": /"] }, + { "category": "face", "char": "🙁", "name": "slightly_frowning_face", "keywords": ["face", "frowning", "disappointed", "sad", "upset"] }, + { "category": "face", "char": "☹", "name": "frowning_face", "keywords": ["face", "sad", "upset", "frown"] }, + { "category": "face", "char": "😣", "name": "persevere", "keywords": ["face", "sick", "no", "upset", "oops"] }, + { "category": "face", "char": "😖", "name": "confounded", "keywords": ["face", "confused", "sick", "unwell", "oops", ": S"] }, + { "category": "face", "char": "😫", "name": "tired_face", "keywords": ["sick", "whine", "upset", "frustrated"] }, + { "category": "face", "char": "😩", "name": "weary", "keywords": ["face", "tired", "sleepy", "sad", "frustrated", "upset"] }, + { "category": "face", "char": "🥺", "name": "pleading", "keywords": ["face", "begging", "mercy"] }, + { "category": "face", "char": "😤", "name": "triumph", "keywords": ["face", "gas", "phew", "proud", "pride"] }, + { "category": "face", "char": "😮", "name": "open_mouth", "keywords": ["face", "surprise", "impressed", "wow", "whoa", ": O"] }, + { "category": "face", "char": "😱", "name": "scream", "keywords": ["face", "munch", "scared", "omg"] }, + { "category": "face", "char": "😨", "name": "fearful", "keywords": ["face", "scared", "terrified", "nervous", "oops", "huh"] }, + { "category": "face", "char": "😰", "name": "cold_sweat", "keywords": ["face", "nervous", "sweat"] }, + { "category": "face", "char": "😯", "name": "hushed", "keywords": ["face", "woo", "shh"] }, + { "category": "face", "char": "😦", "name": "frowning", "keywords": ["face", "aw", "what"] }, + { "category": "face", "char": "😧", "name": "anguished", "keywords": ["face", "stunned", "nervous"] }, + { "category": "face", "char": "😢", "name": "cry", "keywords": ["face", "tears", "sad", "depressed", "upset", ": '("] }, + { "category": "face", "char": "😥", "name": "disappointed_relieved", "keywords": ["face", "phew", "sweat", "nervous"] }, + { "category": "face", "char": "🤤", "name": "drooling_face", "keywords": ["face"] }, + { "category": "face", "char": "😪", "name": "sleepy", "keywords": ["face", "tired", "rest", "nap"] }, + { "category": "face", "char": "😓", "name": "sweat", "keywords": ["face", "hot", "sad", "tired", "exercise"] }, + { "category": "face", "char": "🥵", "name": "hot", "keywords": ["face", "feverish", "heat", "red", "sweating"] }, + { "category": "face", "char": "🥶", "name": "cold", "keywords": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"] }, + { "category": "face", "char": "😭", "name": "sob", "keywords": ["face", "cry", "tears", "sad", "upset", "depressed"] }, + { "category": "face", "char": "😵", "name": "dizzy_face", "keywords": ["spent", "unconscious", "xox", "dizzy"] }, + { "category": "face", "char": "😲", "name": "astonished", "keywords": ["face", "xox", "surprised", "poisoned"] }, + { "category": "face", "char": "🤐", "name": "zipper_mouth_face", "keywords": ["face", "sealed", "zipper", "secret"] }, + { "category": "face", "char": "🤢", "name": "nauseated_face", "keywords": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"] }, + { "category": "face", "char": "🤧", "name": "sneezing_face", "keywords": ["face", "gesundheit", "sneeze", "sick", "allergy"] }, + { "category": "face", "char": "🤮", "name": "vomiting", "keywords": ["face", "sick"] }, + { "category": "face", "char": "😷", "name": "mask", "keywords": ["face", "sick", "ill", "disease"] }, + { "category": "face", "char": "🤒", "name": "face_with_thermometer", "keywords": ["sick", "temperature", "thermometer", "cold", "fever"] }, + { "category": "face", "char": "🤕", "name": "face_with_head_bandage", "keywords": ["injured", "clumsy", "bandage", "hurt"] }, + { "category": "face", "char": "🥴", "name": "woozy", "keywords": ["face", "dizzy", "intoxicated", "tipsy", "wavy"] }, + { "category": "face", "char": "🥱", "name": "yawning", "keywords": ["face", "tired", "yawning"] }, + { "category": "face", "char": "😴", "name": "sleeping", "keywords": ["face", "tired", "sleepy", "night", "zzz"] }, + { "category": "face", "char": "💤", "name": "zzz", "keywords": ["sleepy", "tired", "dream"] }, + { "category": "face", "char": "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", "name": "face_in_clouds", "keywords": [] }, + { "category": "face", "char": "\uD83D\uDE2E\u200D\uD83D\uDCA8", "name": "face_exhaling", "keywords": [] }, + { "category": "face", "char": "\uD83D\uDE35\u200D\uD83D\uDCAB", "name": "face_with_spiral_eyes", "keywords": [] }, + { "category": "face", "char": "💩", "name": "poop", "keywords": ["hankey", "shitface", "fail", "turd", "shit"] }, + { "category": "face", "char": "😈", "name": "smiling_imp", "keywords": ["devil", "horns"] }, + { "category": "face", "char": "👿", "name": "imp", "keywords": ["devil", "angry", "horns"] }, + { "category": "face", "char": "👹", "name": "japanese_ogre", "keywords": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"] }, + { "category": "face", "char": "👺", "name": "japanese_goblin", "keywords": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"] }, + { "category": "face", "char": "💀", "name": "skull", "keywords": ["dead", "skeleton", "creepy", "death"] }, + { "category": "face", "char": "👻", "name": "ghost", "keywords": ["halloween", "spooky", "scary"] }, + { "category": "face", "char": "👽", "name": "alien", "keywords": ["UFO", "paul", "weird", "outer_space"] }, + { "category": "face", "char": "🤖", "name": "robot", "keywords": ["computer", "machine", "bot"] }, + { "category": "face", "char": "😺", "name": "smiley_cat", "keywords": ["animal", "cats", "happy", "smile"] }, + { "category": "face", "char": "😸", "name": "smile_cat", "keywords": ["animal", "cats", "smile"] }, + { "category": "face", "char": "😹", "name": "joy_cat", "keywords": ["animal", "cats", "haha", "happy", "tears"] }, + { "category": "face", "char": "😻", "name": "heart_eyes_cat", "keywords": ["animal", "love", "like", "affection", "cats", "valentines", "heart"] }, + { "category": "face", "char": "😼", "name": "smirk_cat", "keywords": ["animal", "cats", "smirk"] }, + { "category": "face", "char": "😽", "name": "kissing_cat", "keywords": ["animal", "cats", "kiss"] }, + { "category": "face", "char": "🙀", "name": "scream_cat", "keywords": ["animal", "cats", "munch", "scared", "scream"] }, + { "category": "face", "char": "😿", "name": "crying_cat_face", "keywords": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"] }, + { "category": "face", "char": "😾", "name": "pouting_cat", "keywords": ["animal", "cats"] }, + { "category": "people", "char": "🤲", "name": "palms_up", "keywords": ["hands", "gesture", "cupped", "prayer"] }, + { "category": "people", "char": "🙌", "name": "raised_hands", "keywords": ["gesture", "hooray", "yea", "celebration", "hands"] }, + { "category": "people", "char": "👏", "name": "clap", "keywords": ["hands", "praise", "applause", "congrats", "yay"] }, + { "category": "people", "char": "👋", "name": "wave", "keywords": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"] }, + { "category": "people", "char": "🤙", "name": "call_me_hand", "keywords": ["hands", "gesture"] }, + { "category": "people", "char": "👍", "name": "+1", "keywords": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"] }, + { "category": "people", "char": "👎", "name": "-1", "keywords": ["thumbsdown", "no", "dislike", "hand"] }, + { "category": "people", "char": "👊", "name": "facepunch", "keywords": ["angry", "violence", "fist", "hit", "attack", "hand"] }, + { "category": "people", "char": "✊", "name": "fist", "keywords": ["fingers", "hand", "grasp"] }, + { "category": "people", "char": "🤛", "name": "fist_left", "keywords": ["hand", "fistbump"] }, + { "category": "people", "char": "🤜", "name": "fist_right", "keywords": ["hand", "fistbump"] }, + { "category": "people", "char": "✌", "name": "v", "keywords": ["fingers", "ohyeah", "hand", "peace", "victory", "two"] }, + { "category": "people", "char": "👌", "name": "ok_hand", "keywords": ["fingers", "limbs", "perfect", "ok", "okay"] }, + { "category": "people", "char": "✋", "name": "raised_hand", "keywords": ["fingers", "stop", "highfive", "palm", "ban"] }, + { "category": "people", "char": "🤚", "name": "raised_back_of_hand", "keywords": ["fingers", "raised", "backhand"] }, + { "category": "people", "char": "👐", "name": "open_hands", "keywords": ["fingers", "butterfly", "hands", "open"] }, + { "category": "people", "char": "💪", "name": "muscle", "keywords": ["arm", "flex", "hand", "summer", "strong", "biceps"] }, + { "category": "people", "char": "🦾", "name": "mechanical_arm", "keywords": ["flex", "hand", "strong", "biceps"] }, + { "category": "people", "char": "🙏", "name": "pray", "keywords": ["please", "hope", "wish", "namaste", "highfive"] }, + { "category": "people", "char": "🦶", "name": "foot", "keywords": ["kick", "stomp"] }, + { "category": "people", "char": "🦵", "name": "leg", "keywords": ["kick", "limb"] }, + { "category": "people", "char": "🦿", "name": "mechanical_leg", "keywords": ["kick", "limb"] }, + { "category": "people", "char": "🤝", "name": "handshake", "keywords": ["agreement", "shake"] }, + { "category": "people", "char": "☝", "name": "point_up", "keywords": ["hand", "fingers", "direction", "up"] }, + { "category": "people", "char": "👆", "name": "point_up_2", "keywords": ["fingers", "hand", "direction", "up"] }, + { "category": "people", "char": "👇", "name": "point_down", "keywords": ["fingers", "hand", "direction", "down"] }, + { "category": "people", "char": "👈", "name": "point_left", "keywords": ["direction", "fingers", "hand", "left"] }, + { "category": "people", "char": "👉", "name": "point_right", "keywords": ["fingers", "hand", "direction", "right"] }, + { "category": "people", "char": "🖕", "name": "fu", "keywords": ["hand", "fingers", "rude", "middle", "flipping"] }, + { "category": "people", "char": "🖐", "name": "raised_hand_with_fingers_splayed", "keywords": ["hand", "fingers", "palm"] }, + { "category": "people", "char": "🤟", "name": "love_you", "keywords": ["hand", "fingers", "gesture"] }, + { "category": "people", "char": "🤘", "name": "metal", "keywords": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"] }, + { "category": "people", "char": "🤞", "name": "crossed_fingers", "keywords": ["good", "lucky"] }, + { "category": "people", "char": "🖖", "name": "vulcan_salute", "keywords": ["hand", "fingers", "spock", "star trek"] }, + { "category": "people", "char": "✍", "name": "writing_hand", "keywords": ["lower_left_ballpoint_pen", "stationery", "write", "compose"] }, + { "category": "people", "char": "🤏", "name": "pinching_hand", "keywords": ["hand", "fingers"] }, + { "category": "people", "char": "🤌", "name": "pinched_fingers", "keywords": ["hand", "fingers"] }, + { "category": "people", "char": "🤳", "name": "selfie", "keywords": ["camera", "phone"] }, + { "category": "people", "char": "💅", "name": "nail_care", "keywords": ["beauty", "manicure", "finger", "fashion", "nail"] }, + { "category": "people", "char": "👄", "name": "lips", "keywords": ["mouth", "kiss"] }, + { "category": "people", "char": "🦷", "name": "tooth", "keywords": ["teeth", "dentist"] }, + { "category": "people", "char": "👅", "name": "tongue", "keywords": ["mouth", "playful"] }, + { "category": "people", "char": "👂", "name": "ear", "keywords": ["face", "hear", "sound", "listen"] }, + { "category": "people", "char": "🦻", "name": "ear_with_hearing_aid", "keywords": ["face", "hear", "sound", "listen"] }, + { "category": "people", "char": "👃", "name": "nose", "keywords": ["smell", "sniff"] }, + { "category": "people", "char": "👁", "name": "eye", "keywords": ["face", "look", "see", "watch", "stare"] }, + { "category": "people", "char": "👀", "name": "eyes", "keywords": ["look", "watch", "stalk", "peek", "see"] }, + { "category": "people", "char": "🧠", "name": "brain", "keywords": ["smart", "intelligent"] }, + { "category": "people", "char": "🫀", "name": "anatomical_heart", "keywords": [] }, + { "category": "people", "char": "🫁", "name": "lungs", "keywords": [] }, + { "category": "people", "char": "👤", "name": "bust_in_silhouette", "keywords": ["user", "person", "human"] }, + { "category": "people", "char": "👥", "name": "busts_in_silhouette", "keywords": ["user", "person", "human", "group", "team"] }, + { "category": "people", "char": "🗣", "name": "speaking_head", "keywords": ["user", "person", "human", "sing", "say", "talk"] }, + { "category": "people", "char": "👶", "name": "baby", "keywords": ["child", "boy", "girl", "toddler"] }, + { "category": "people", "char": "🧒", "name": "child", "keywords": ["gender-neutral", "young"] }, + { "category": "people", "char": "👦", "name": "boy", "keywords": ["man", "male", "guy", "teenager"] }, + { "category": "people", "char": "👧", "name": "girl", "keywords": ["female", "woman", "teenager"] }, + { "category": "people", "char": "🧑", "name": "adult", "keywords": ["gender-neutral", "person"] }, + { "category": "people", "char": "👨", "name": "man", "keywords": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"] }, + { "category": "people", "char": "👩", "name": "woman", "keywords": ["female", "girls", "lady"] }, + { "category": "people", "char": "🧑🦱", "name": "curly_hair", "keywords": ["curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "👩🦱", "name": "curly_hair_woman", "keywords": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "👨🦱", "name": "curly_hair_man", "keywords": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "🧑🦰", "name": "red_hair", "keywords": ["redhead"] }, + { "category": "people", "char": "👩🦰", "name": "red_hair_woman", "keywords": ["woman", "female", "girl", "ginger", "redhead"] }, + { "category": "people", "char": "👨🦰", "name": "red_hair_man", "keywords": ["man", "male", "boy", "guy", "ginger", "redhead"] }, + { "category": "people", "char": "👱♀️", "name": "blonde_woman", "keywords": ["woman", "female", "girl", "blonde", "person"] }, + { "category": "people", "char": "👱", "name": "blonde_man", "keywords": ["man", "male", "boy", "blonde", "guy", "person"] }, + { "category": "people", "char": "🧑🦳", "name": "white_hair", "keywords": ["gray", "old", "white"] }, + { "category": "people", "char": "👩🦳", "name": "white_hair_woman", "keywords": ["woman", "female", "girl", "gray", "old", "white"] }, + { "category": "people", "char": "👨🦳", "name": "white_hair_man", "keywords": ["man", "male", "boy", "guy", "gray", "old", "white"] }, + { "category": "people", "char": "🧑🦲", "name": "bald", "keywords": ["bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "👩🦲", "name": "bald_woman", "keywords": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "👨🦲", "name": "bald_man", "keywords": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "🧔", "name": "bearded_person", "keywords": ["person", "bewhiskered"] }, + { "category": "people", "char": "🧓", "name": "older_adult", "keywords": ["human", "elder", "senior", "gender-neutral"] }, + { "category": "people", "char": "👴", "name": "older_man", "keywords": ["human", "male", "men", "old", "elder", "senior"] }, + { "category": "people", "char": "👵", "name": "older_woman", "keywords": ["human", "female", "women", "lady", "old", "elder", "senior"] }, + { "category": "people", "char": "👲", "name": "man_with_gua_pi_mao", "keywords": ["male", "boy", "chinese"] }, + { "category": "people", "char": "🧕", "name": "woman_with_headscarf", "keywords": ["female", "hijab", "mantilla", "tichel"] }, + { "category": "people", "char": "👳♀️", "name": "woman_with_turban", "keywords": ["female", "indian", "hinduism", "arabs", "woman"] }, + { "category": "people", "char": "👳", "name": "man_with_turban", "keywords": ["male", "indian", "hinduism", "arabs"] }, + { "category": "people", "char": "👮♀️", "name": "policewoman", "keywords": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"] }, + { "category": "people", "char": "👮", "name": "policeman", "keywords": ["man", "police", "law", "legal", "enforcement", "arrest", "911"] }, + { "category": "people", "char": "👷♀️", "name": "construction_worker_woman", "keywords": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"] }, + { "category": "people", "char": "👷", "name": "construction_worker_man", "keywords": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"] }, + { "category": "people", "char": "💂♀️", "name": "guardswoman", "keywords": ["uk", "gb", "british", "female", "royal", "woman"] }, + { "category": "people", "char": "💂", "name": "guardsman", "keywords": ["uk", "gb", "british", "male", "guy", "royal"] }, + { "category": "people", "char": "🕵️♀️", "name": "female_detective", "keywords": ["human", "spy", "detective", "female", "woman"] }, + { "category": "people", "char": "🕵", "name": "male_detective", "keywords": ["human", "spy", "detective"] }, + { "category": "people", "char": "🧑⚕️", "name": "health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "human"] }, + { "category": "people", "char": "👩⚕️", "name": "woman_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"] }, + { "category": "people", "char": "👨⚕️", "name": "man_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "man", "human"] }, + { "category": "people", "char": "🧑🌾", "name": "farmer", "keywords": ["rancher", "gardener", "human"] }, + { "category": "people", "char": "👩🌾", "name": "woman_farmer", "keywords": ["rancher", "gardener", "woman", "human"] }, + { "category": "people", "char": "👨🌾", "name": "man_farmer", "keywords": ["rancher", "gardener", "man", "human"] }, + { "category": "people", "char": "🧑🍳", "name": "cook", "keywords": ["chef", "human"] }, + { "category": "people", "char": "👩🍳", "name": "woman_cook", "keywords": ["chef", "woman", "human"] }, + { "category": "people", "char": "👨🍳", "name": "man_cook", "keywords": ["chef", "man", "human"] }, + { "category": "people", "char": "🧑🎓", "name": "student", "keywords": ["graduate", "human"] }, + { "category": "people", "char": "👩🎓", "name": "woman_student", "keywords": ["graduate", "woman", "human"] }, + { "category": "people", "char": "👨🎓", "name": "man_student", "keywords": ["graduate", "man", "human"] }, + { "category": "people", "char": "🧑🎤", "name": "singer", "keywords": ["rockstar", "entertainer", "human"] }, + { "category": "people", "char": "👩🎤", "name": "woman_singer", "keywords": ["rockstar", "entertainer", "woman", "human"] }, + { "category": "people", "char": "👨🎤", "name": "man_singer", "keywords": ["rockstar", "entertainer", "man", "human"] }, + { "category": "people", "char": "🧑🏫", "name": "teacher", "keywords": ["instructor", "professor", "human"] }, + { "category": "people", "char": "👩🏫", "name": "woman_teacher", "keywords": ["instructor", "professor", "woman", "human"] }, + { "category": "people", "char": "👨🏫", "name": "man_teacher", "keywords": ["instructor", "professor", "man", "human"] }, + { "category": "people", "char": "🧑🏭", "name": "factory_worker", "keywords": ["assembly", "industrial", "human"] }, + { "category": "people", "char": "👩🏭", "name": "woman_factory_worker", "keywords": ["assembly", "industrial", "woman", "human"] }, + { "category": "people", "char": "👨🏭", "name": "man_factory_worker", "keywords": ["assembly", "industrial", "man", "human"] }, + { "category": "people", "char": "🧑💻", "name": "technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"] }, + { "category": "people", "char": "👩💻", "name": "woman_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"] }, + { "category": "people", "char": "👨💻", "name": "man_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"] }, + { "category": "people", "char": "🧑💼", "name": "office_worker", "keywords": ["business", "manager", "human"] }, + { "category": "people", "char": "👩💼", "name": "woman_office_worker", "keywords": ["business", "manager", "woman", "human"] }, + { "category": "people", "char": "👨💼", "name": "man_office_worker", "keywords": ["business", "manager", "man", "human"] }, + { "category": "people", "char": "🧑🔧", "name": "mechanic", "keywords": ["plumber", "human", "wrench"] }, + { "category": "people", "char": "👩🔧", "name": "woman_mechanic", "keywords": ["plumber", "woman", "human", "wrench"] }, + { "category": "people", "char": "👨🔧", "name": "man_mechanic", "keywords": ["plumber", "man", "human", "wrench"] }, + { "category": "people", "char": "🧑🔬", "name": "scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "human"] }, + { "category": "people", "char": "👩🔬", "name": "woman_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "woman", "human"] }, + { "category": "people", "char": "👨🔬", "name": "man_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "man", "human"] }, + { "category": "people", "char": "🧑🎨", "name": "artist", "keywords": ["painter", "human"] }, + { "category": "people", "char": "👩🎨", "name": "woman_artist", "keywords": ["painter", "woman", "human"] }, + { "category": "people", "char": "👨🎨", "name": "man_artist", "keywords": ["painter", "man", "human"] }, + { "category": "people", "char": "🧑🚒", "name": "firefighter", "keywords": ["fireman", "human"] }, + { "category": "people", "char": "👩🚒", "name": "woman_firefighter", "keywords": ["fireman", "woman", "human"] }, + { "category": "people", "char": "👨🚒", "name": "man_firefighter", "keywords": ["fireman", "man", "human"] }, + { "category": "people", "char": "🧑✈️", "name": "pilot", "keywords": ["aviator", "plane", "human"] }, + { "category": "people", "char": "👩✈️", "name": "woman_pilot", "keywords": ["aviator", "plane", "woman", "human"] }, + { "category": "people", "char": "👨✈️", "name": "man_pilot", "keywords": ["aviator", "plane", "man", "human"] }, + { "category": "people", "char": "🧑🚀", "name": "astronaut", "keywords": ["space", "rocket", "human"] }, + { "category": "people", "char": "👩🚀", "name": "woman_astronaut", "keywords": ["space", "rocket", "woman", "human"] }, + { "category": "people", "char": "👨🚀", "name": "man_astronaut", "keywords": ["space", "rocket", "man", "human"] }, + { "category": "people", "char": "🧑⚖️", "name": "judge", "keywords": ["justice", "court", "human"] }, + { "category": "people", "char": "👩⚖️", "name": "woman_judge", "keywords": ["justice", "court", "woman", "human"] }, + { "category": "people", "char": "👨⚖️", "name": "man_judge", "keywords": ["justice", "court", "man", "human"] }, + { "category": "people", "char": "🦸♀️", "name": "woman_superhero", "keywords": ["woman", "female", "good", "heroine", "superpowers"] }, + { "category": "people", "char": "🦸♂️", "name": "man_superhero", "keywords": ["man", "male", "good", "hero", "superpowers"] }, + { "category": "people", "char": "🦹♀️", "name": "woman_supervillain", "keywords": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"] }, + { "category": "people", "char": "🦹♂️", "name": "man_supervillain", "keywords": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"] }, + { "category": "people", "char": "🤶", "name": "mrs_claus", "keywords": ["woman", "female", "xmas", "mother christmas"] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF84", "name": "mx_claus", "keywords": ["xmas", "christmas"] }, + { "category": "people", "char": "🎅", "name": "santa", "keywords": ["festival", "man", "male", "xmas", "father christmas"] }, + { "category": "people", "char": "🥷", "name": "ninja", "keywords": [] }, + { "category": "people", "char": "🧙♀️", "name": "sorceress", "keywords": ["woman", "female", "mage", "witch"] }, + { "category": "people", "char": "🧙♂️", "name": "wizard", "keywords": ["man", "male", "mage", "sorcerer"] }, + { "category": "people", "char": "🧝♀️", "name": "woman_elf", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧝♂️", "name": "man_elf", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧛♀️", "name": "woman_vampire", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧛♂️", "name": "man_vampire", "keywords": ["man", "male", "dracula"] }, + { "category": "people", "char": "🧟♀️", "name": "woman_zombie", "keywords": ["woman", "female", "undead", "walking dead"] }, + { "category": "people", "char": "🧟♂️", "name": "man_zombie", "keywords": ["man", "male", "dracula", "undead", "walking dead"] }, + { "category": "people", "char": "🧞♀️", "name": "woman_genie", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧞♂️", "name": "man_genie", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧜♀️", "name": "mermaid", "keywords": ["woman", "female", "merwoman", "ariel"] }, + { "category": "people", "char": "🧜♂️", "name": "merman", "keywords": ["man", "male", "triton"] }, + { "category": "people", "char": "🧚♀️", "name": "woman_fairy", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧚♂️", "name": "man_fairy", "keywords": ["man", "male"] }, + { "category": "people", "char": "👼", "name": "angel", "keywords": ["heaven", "wings", "halo"] }, + { "category": "people", "char": "🤰", "name": "pregnant_woman", "keywords": ["baby"] }, + { "category": "people", "char": "🤱", "name": "breastfeeding", "keywords": ["nursing", "baby"] }, + { "category": "people", "char": "\uD83D\uDC69\u200D\uD83C\uDF7C", "name": "woman_feeding_baby", "keywords": [] }, + { "category": "people", "char": "\uD83D\uDC68\u200D\uD83C\uDF7C", "name": "man_feeding_baby", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF7C", "name": "person_feeding_baby", "keywords": [] }, + { "category": "people", "char": "👸", "name": "princess", "keywords": ["girl", "woman", "female", "blond", "crown", "royal", "queen"] }, + { "category": "people", "char": "🤴", "name": "prince", "keywords": ["boy", "man", "male", "crown", "royal", "king"] }, + { "category": "people", "char": "👰", "name": "person_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, + { "category": "people", "char": "👰", "name": "bride_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, + { "category": "people", "char": "🤵", "name": "person_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, + { "category": "people", "char": "🤵", "name": "man_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, + { "category": "people", "char": "🏃♀️", "name": "running_woman", "keywords": ["woman", "walking", "exercise", "race", "running", "female"] }, + { "category": "people", "char": "🏃", "name": "running_man", "keywords": ["man", "walking", "exercise", "race", "running"] }, + { "category": "people", "char": "🚶♀️", "name": "walking_woman", "keywords": ["human", "feet", "steps", "woman", "female"] }, + { "category": "people", "char": "🚶", "name": "walking_man", "keywords": ["human", "feet", "steps"] }, + { "category": "people", "char": "💃", "name": "dancer", "keywords": ["female", "girl", "woman", "fun"] }, + { "category": "people", "char": "🕺", "name": "man_dancing", "keywords": ["male", "boy", "fun", "dancer"] }, + { "category": "people", "char": "👯", "name": "dancing_women", "keywords": ["female", "bunny", "women", "girls"] }, + { "category": "people", "char": "👯♂️", "name": "dancing_men", "keywords": ["male", "bunny", "men", "boys"] }, + { "category": "people", "char": "👫", "name": "couple", "keywords": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", "name": "people_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"] }, + { "category": "people", "char": "👬", "name": "two_men_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"] }, + { "category": "people", "char": "👭", "name": "two_women_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"] }, + { "category": "people", "char": "🫂", "name": "people_hugging", "keywords": [] }, + { "category": "people", "char": "🙇♀️", "name": "bowing_woman", "keywords": ["woman", "female", "girl"] }, + { "category": "people", "char": "🙇", "name": "bowing_man", "keywords": ["man", "male", "boy"] }, + { "category": "people", "char": "🤦♂️", "name": "man_facepalming", "keywords": ["man", "male", "boy", "disbelief"] }, + { "category": "people", "char": "🤦♀️", "name": "woman_facepalming", "keywords": ["woman", "female", "girl", "disbelief"] }, + { "category": "people", "char": "🤷", "name": "woman_shrugging", "keywords": ["woman", "female", "girl", "confused", "indifferent", "doubt"] }, + { "category": "people", "char": "🤷♂️", "name": "man_shrugging", "keywords": ["man", "male", "boy", "confused", "indifferent", "doubt"] }, + { "category": "people", "char": "💁", "name": "tipping_hand_woman", "keywords": ["female", "girl", "woman", "human", "information"] }, + { "category": "people", "char": "💁♂️", "name": "tipping_hand_man", "keywords": ["male", "boy", "man", "human", "information"] }, + { "category": "people", "char": "🙅", "name": "no_good_woman", "keywords": ["female", "girl", "woman", "nope"] }, + { "category": "people", "char": "🙅♂️", "name": "no_good_man", "keywords": ["male", "boy", "man", "nope"] }, + { "category": "people", "char": "🙆", "name": "ok_woman", "keywords": ["women", "girl", "female", "pink", "human", "woman"] }, + { "category": "people", "char": "🙆♂️", "name": "ok_man", "keywords": ["men", "boy", "male", "blue", "human", "man"] }, + { "category": "people", "char": "🙋", "name": "raising_hand_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "🙋♂️", "name": "raising_hand_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "🙎", "name": "pouting_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "🙎♂️", "name": "pouting_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "🙍", "name": "frowning_woman", "keywords": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"] }, + { "category": "people", "char": "🙍♂️", "name": "frowning_man", "keywords": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"] }, + { "category": "people", "char": "💇", "name": "haircut_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "💇♂️", "name": "haircut_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "💆", "name": "massage_woman", "keywords": ["female", "girl", "woman", "head"] }, + { "category": "people", "char": "💆♂️", "name": "massage_man", "keywords": ["male", "boy", "man", "head"] }, + { "category": "people", "char": "🧖♀️", "name": "woman_in_steamy_room", "keywords": ["female", "woman", "spa", "steamroom", "sauna"] }, + { "category": "people", "char": "🧖♂️", "name": "man_in_steamy_room", "keywords": ["male", "man", "spa", "steamroom", "sauna"] }, + { "category": "people", "char": "🧏♀️", "name": "woman_deaf", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧏♂️", "name": "man_deaf", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧍♀️", "name": "woman_standing", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧍♂️", "name": "man_standing", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧎♀️", "name": "woman_kneeling", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧎♂️", "name": "man_kneeling", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧑🦯", "name": "person_with_probing_cane", "keywords": ["accessibility", "blind"] }, + { "category": "people", "char": "👩🦯", "name": "woman_with_probing_cane", "keywords": ["woman", "female", "accessibility", "blind"] }, + { "category": "people", "char": "👨🦯", "name": "man_with_probing_cane", "keywords": ["man", "male", "accessibility", "blind"] }, + { "category": "people", "char": "🧑🦼", "name": "person_in_motorized_wheelchair", "keywords": ["accessibility"] }, + { "category": "people", "char": "👩🦼", "name": "woman_in_motorized_wheelchair", "keywords": ["woman", "female", "accessibility"] }, + { "category": "people", "char": "👨🦼", "name": "man_in_motorized_wheelchair", "keywords": ["man", "male", "accessibility"] }, + { "category": "people", "char": "🧑🦽", "name": "person_in_manual_wheelchair", "keywords": ["accessibility"] }, + { "category": "people", "char": "👩🦽", "name": "woman_in_manual_wheelchair", "keywords": ["woman", "female", "accessibility"] }, + { "category": "people", "char": "👨🦽", "name": "man_in_manual_wheelchair", "keywords": ["man", "male", "accessibility"] }, + { "category": "people", "char": "💑", "name": "couple_with_heart_woman_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "👩❤️👩", "name": "couple_with_heart_woman_woman", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "👨❤️👨", "name": "couple_with_heart_man_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "💏", "name": "couplekiss_man_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👩❤️💋👩", "name": "couplekiss_woman_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👨❤️💋👨", "name": "couplekiss_man_man", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👪", "name": "family_man_woman_boy", "keywords": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"] }, + { "category": "people", "char": "👨👩👧", "name": "family_man_woman_girl", "keywords": ["home", "parents", "people", "human", "child"] }, + { "category": "people", "char": "👨👩👧👦", "name": "family_man_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👩👦👦", "name": "family_man_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👩👧👧", "name": "family_man_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👦", "name": "family_woman_woman_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👧", "name": "family_woman_woman_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👧👦", "name": "family_woman_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👦👦", "name": "family_woman_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👩👧👧", "name": "family_woman_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👦", "name": "family_man_man_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👧", "name": "family_man_man_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👧👦", "name": "family_man_man_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👦👦", "name": "family_man_man_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨👨👧👧", "name": "family_man_man_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩👦", "name": "family_woman_boy", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👩👧", "name": "family_woman_girl", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👩👧👦", "name": "family_woman_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👩👦👦", "name": "family_woman_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👩👧👧", "name": "family_woman_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨👦", "name": "family_man_boy", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👨👧", "name": "family_man_girl", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👨👧👦", "name": "family_man_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨👦👦", "name": "family_man_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨👧👧", "name": "family_man_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "🧶", "name": "yarn", "keywords": ["ball", "crochet", "knit"] }, + { "category": "people", "char": "🧵", "name": "thread", "keywords": ["needle", "sewing", "spool", "string"] }, + { "category": "people", "char": "🧥", "name": "coat", "keywords": ["jacket"] }, + { "category": "people", "char": "🥼", "name": "labcoat", "keywords": ["doctor", "experiment", "scientist", "chemist"] }, + { "category": "people", "char": "👚", "name": "womans_clothes", "keywords": ["fashion", "shopping_bags", "female"] }, + { "category": "people", "char": "👕", "name": "tshirt", "keywords": ["fashion", "cloth", "casual", "shirt", "tee"] }, + { "category": "people", "char": "👖", "name": "jeans", "keywords": ["fashion", "shopping"] }, + { "category": "people", "char": "👔", "name": "necktie", "keywords": ["shirt", "suitup", "formal", "fashion", "cloth", "business"] }, + { "category": "people", "char": "👗", "name": "dress", "keywords": ["clothes", "fashion", "shopping"] }, + { "category": "people", "char": "👙", "name": "bikini", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, + { "category": "people", "char": "🩱", "name": "one_piece_swimsuit", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, + { "category": "people", "char": "👘", "name": "kimono", "keywords": ["dress", "fashion", "women", "female", "japanese"] }, + { "category": "people", "char": "🥻", "name": "sari", "keywords": ["dress", "fashion", "women", "female"] }, + { "category": "people", "char": "🩲", "name": "briefs", "keywords": ["dress", "fashion"] }, + { "category": "people", "char": "🩳", "name": "shorts", "keywords": ["dress", "fashion"] }, + { "category": "people", "char": "💄", "name": "lipstick", "keywords": ["female", "girl", "fashion", "woman"] }, + { "category": "people", "char": "💋", "name": "kiss", "keywords": ["face", "lips", "love", "like", "affection", "valentines"] }, + { "category": "people", "char": "👣", "name": "footprints", "keywords": ["feet", "tracking", "walking", "beach"] }, + { "category": "people", "char": "🥿", "name": "flat_shoe", "keywords": ["ballet", "slip-on", "slipper"] }, + { "category": "people", "char": "👠", "name": "high_heel", "keywords": ["fashion", "shoes", "female", "pumps", "stiletto"] }, + { "category": "people", "char": "👡", "name": "sandal", "keywords": ["shoes", "fashion", "flip flops"] }, + { "category": "people", "char": "👢", "name": "boot", "keywords": ["shoes", "fashion"] }, + { "category": "people", "char": "👞", "name": "mans_shoe", "keywords": ["fashion", "male"] }, + { "category": "people", "char": "👟", "name": "athletic_shoe", "keywords": ["shoes", "sports", "sneakers"] }, + { "category": "people", "char": "🩴", "name": "thong_sandal", "keywords": [] }, + { "category": "people", "char": "🩰", "name": "ballet_shoes", "keywords": ["shoes", "sports"] }, + { "category": "people", "char": "🧦", "name": "socks", "keywords": ["stockings", "clothes"] }, + { "category": "people", "char": "🧤", "name": "gloves", "keywords": ["hands", "winter", "clothes"] }, + { "category": "people", "char": "🧣", "name": "scarf", "keywords": ["neck", "winter", "clothes"] }, + { "category": "people", "char": "👒", "name": "womans_hat", "keywords": ["fashion", "accessories", "female", "lady", "spring"] }, + { "category": "people", "char": "🎩", "name": "tophat", "keywords": ["magic", "gentleman", "classy", "circus"] }, + { "category": "people", "char": "🧢", "name": "billed_hat", "keywords": ["cap", "baseball"] }, + { "category": "people", "char": "⛑", "name": "rescue_worker_helmet", "keywords": ["construction", "build"] }, + { "category": "people", "char": "🪖", "name": "military_helmet", "keywords": [] }, + { "category": "people", "char": "🎓", "name": "mortar_board", "keywords": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"] }, + { "category": "people", "char": "👑", "name": "crown", "keywords": ["king", "kod", "leader", "royalty", "lord"] }, + { "category": "people", "char": "🎒", "name": "school_satchel", "keywords": ["student", "education", "bag", "backpack"] }, + { "category": "people", "char": "🧳", "name": "luggage", "keywords": ["packing", "travel"] }, + { "category": "people", "char": "👝", "name": "pouch", "keywords": ["bag", "accessories", "shopping"] }, + { "category": "people", "char": "👛", "name": "purse", "keywords": ["fashion", "accessories", "money", "sales", "shopping"] }, + { "category": "people", "char": "👜", "name": "handbag", "keywords": ["fashion", "accessory", "accessories", "shopping"] }, + { "category": "people", "char": "💼", "name": "briefcase", "keywords": ["business", "documents", "work", "law", "legal", "job", "career"] }, + { "category": "people", "char": "👓", "name": "eyeglasses", "keywords": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"] }, + { "category": "people", "char": "🕶", "name": "dark_sunglasses", "keywords": ["face", "cool", "accessories"] }, + { "category": "people", "char": "🥽", "name": "goggles", "keywords": ["eyes", "protection", "safety"] }, + { "category": "people", "char": "💍", "name": "ring", "keywords": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"] }, + { "category": "people", "char": "🌂", "name": "closed_umbrella", "keywords": ["weather", "rain", "drizzle"] }, + { "category": "animals_and_nature", "char": "🐶", "name": "dog", "keywords": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"] }, + { "category": "animals_and_nature", "char": "🐱", "name": "cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, + { "category": "animals_and_nature", "char": "🐈⬛", "name": "black_cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, + { "category": "animals_and_nature", "char": "🐭", "name": "mouse", "keywords": ["animal", "nature", "cheese_wedge", "rodent"] }, + { "category": "animals_and_nature", "char": "🐹", "name": "hamster", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐰", "name": "rabbit", "keywords": ["animal", "nature", "pet", "spring", "magic", "bunny"] }, + { "category": "animals_and_nature", "char": "🦊", "name": "fox_face", "keywords": ["animal", "nature", "face"] }, + { "category": "animals_and_nature", "char": "🐻", "name": "bear", "keywords": ["animal", "nature", "wild"] }, + { "category": "animals_and_nature", "char": "🐼", "name": "panda_face", "keywords": ["animal", "nature", "panda"] }, + { "category": "animals_and_nature", "char": "🐨", "name": "koala", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐯", "name": "tiger", "keywords": ["animal", "cat", "danger", "wild", "nature", "roar"] }, + { "category": "animals_and_nature", "char": "🦁", "name": "lion", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐮", "name": "cow", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, + { "category": "animals_and_nature", "char": "🐷", "name": "pig", "keywords": ["animal", "oink", "nature"] }, + { "category": "animals_and_nature", "char": "🐽", "name": "pig_nose", "keywords": ["animal", "oink"] }, + { "category": "animals_and_nature", "char": "🐸", "name": "frog", "keywords": ["animal", "nature", "croak", "toad"] }, + { "category": "animals_and_nature", "char": "🦑", "name": "squid", "keywords": ["animal", "nature", "ocean", "sea"] }, + { "category": "animals_and_nature", "char": "🐙", "name": "octopus", "keywords": ["animal", "creature", "ocean", "sea", "nature", "beach"] }, + { "category": "animals_and_nature", "char": "🦐", "name": "shrimp", "keywords": ["animal", "ocean", "nature", "seafood"] }, + { "category": "animals_and_nature", "char": "🐵", "name": "monkey_face", "keywords": ["animal", "nature", "circus"] }, + { "category": "animals_and_nature", "char": "🦍", "name": "gorilla", "keywords": ["animal", "nature", "circus"] }, + { "category": "animals_and_nature", "char": "🙈", "name": "see_no_evil", "keywords": ["monkey", "animal", "nature", "haha"] }, + { "category": "animals_and_nature", "char": "🙉", "name": "hear_no_evil", "keywords": ["animal", "monkey", "nature"] }, + { "category": "animals_and_nature", "char": "🙊", "name": "speak_no_evil", "keywords": ["monkey", "animal", "nature", "omg"] }, + { "category": "animals_and_nature", "char": "🐒", "name": "monkey", "keywords": ["animal", "nature", "banana", "circus"] }, + { "category": "animals_and_nature", "char": "🐔", "name": "chicken", "keywords": ["animal", "cluck", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🐧", "name": "penguin", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐦", "name": "bird", "keywords": ["animal", "nature", "fly", "tweet", "spring"] }, + { "category": "animals_and_nature", "char": "🐤", "name": "baby_chick", "keywords": ["animal", "chicken", "bird"] }, + { "category": "animals_and_nature", "char": "🐣", "name": "hatching_chick", "keywords": ["animal", "chicken", "egg", "born", "baby", "bird"] }, + { "category": "animals_and_nature", "char": "🐥", "name": "hatched_chick", "keywords": ["animal", "chicken", "baby", "bird"] }, + { "category": "animals_and_nature", "char": "🦆", "name": "duck", "keywords": ["animal", "nature", "bird", "mallard"] }, + { "category": "animals_and_nature", "char": "🦅", "name": "eagle", "keywords": ["animal", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🦉", "name": "owl", "keywords": ["animal", "nature", "bird", "hoot"] }, + { "category": "animals_and_nature", "char": "🦇", "name": "bat", "keywords": ["animal", "nature", "blind", "vampire"] }, + { "category": "animals_and_nature", "char": "🐺", "name": "wolf", "keywords": ["animal", "nature", "wild"] }, + { "category": "animals_and_nature", "char": "🐗", "name": "boar", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐴", "name": "horse", "keywords": ["animal", "brown", "nature"] }, + { "category": "animals_and_nature", "char": "🦄", "name": "unicorn", "keywords": ["animal", "nature", "mystical"] }, + { "category": "animals_and_nature", "char": "🐝", "name": "honeybee", "keywords": ["animal", "insect", "nature", "bug", "spring", "honey"] }, + { "category": "animals_and_nature", "char": "🐛", "name": "bug", "keywords": ["animal", "insect", "nature", "worm"] }, + { "category": "animals_and_nature", "char": "🦋", "name": "butterfly", "keywords": ["animal", "insect", "nature", "caterpillar"] }, + { "category": "animals_and_nature", "char": "🐌", "name": "snail", "keywords": ["slow", "animal", "shell"] }, + { "category": "animals_and_nature", "char": "🐞", "name": "beetle", "keywords": ["animal", "insect", "nature", "ladybug"] }, + { "category": "animals_and_nature", "char": "🐜", "name": "ant", "keywords": ["animal", "insect", "nature", "bug"] }, + { "category": "animals_and_nature", "char": "🦗", "name": "grasshopper", "keywords": ["animal", "cricket", "chirp"] }, + { "category": "animals_and_nature", "char": "🕷", "name": "spider", "keywords": ["animal", "arachnid"] }, + { "category": "animals_and_nature", "char": "🪲", "name": "beetle", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪳", "name": "cockroach", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪰", "name": "fly", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪱", "name": "worm", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🦂", "name": "scorpion", "keywords": ["animal", "arachnid"] }, + { "category": "animals_and_nature", "char": "🦀", "name": "crab", "keywords": ["animal", "crustacean"] }, + { "category": "animals_and_nature", "char": "🐍", "name": "snake", "keywords": ["animal", "evil", "nature", "hiss", "python"] }, + { "category": "animals_and_nature", "char": "🦎", "name": "lizard", "keywords": ["animal", "nature", "reptile"] }, + { "category": "animals_and_nature", "char": "🦖", "name": "t-rex", "keywords": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"] }, + { "category": "animals_and_nature", "char": "🦕", "name": "sauropod", "keywords": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"] }, + { "category": "animals_and_nature", "char": "🐢", "name": "turtle", "keywords": ["animal", "slow", "nature", "tortoise"] }, + { "category": "animals_and_nature", "char": "🐠", "name": "tropical_fish", "keywords": ["animal", "swim", "ocean", "beach", "nemo"] }, + { "category": "animals_and_nature", "char": "🐟", "name": "fish", "keywords": ["animal", "food", "nature"] }, + { "category": "animals_and_nature", "char": "🐡", "name": "blowfish", "keywords": ["animal", "nature", "food", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐬", "name": "dolphin", "keywords": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"] }, + { "category": "animals_and_nature", "char": "🦈", "name": "shark", "keywords": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"] }, + { "category": "animals_and_nature", "char": "🐳", "name": "whale", "keywords": ["animal", "nature", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐋", "name": "whale2", "keywords": ["animal", "nature", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐊", "name": "crocodile", "keywords": ["animal", "nature", "reptile", "lizard", "alligator"] }, + { "category": "animals_and_nature", "char": "🐆", "name": "leopard", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦓", "name": "zebra", "keywords": ["animal", "nature", "stripes", "safari"] }, + { "category": "animals_and_nature", "char": "🐅", "name": "tiger2", "keywords": ["animal", "nature", "roar"] }, + { "category": "animals_and_nature", "char": "🐃", "name": "water_buffalo", "keywords": ["animal", "nature", "ox", "cow"] }, + { "category": "animals_and_nature", "char": "🐂", "name": "ox", "keywords": ["animal", "cow", "beef"] }, + { "category": "animals_and_nature", "char": "🐄", "name": "cow2", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, + { "category": "animals_and_nature", "char": "🦌", "name": "deer", "keywords": ["animal", "nature", "horns", "venison"] }, + { "category": "animals_and_nature", "char": "🐪", "name": "dromedary_camel", "keywords": ["animal", "hot", "desert", "hump"] }, + { "category": "animals_and_nature", "char": "🐫", "name": "camel", "keywords": ["animal", "nature", "hot", "desert", "hump"] }, + { "category": "animals_and_nature", "char": "🦒", "name": "giraffe", "keywords": ["animal", "nature", "spots", "safari"] }, + { "category": "animals_and_nature", "char": "🐘", "name": "elephant", "keywords": ["animal", "nature", "nose", "th", "circus"] }, + { "category": "animals_and_nature", "char": "🦏", "name": "rhinoceros", "keywords": ["animal", "nature", "horn"] }, + { "category": "animals_and_nature", "char": "🐐", "name": "goat", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐏", "name": "ram", "keywords": ["animal", "sheep", "nature"] }, + { "category": "animals_and_nature", "char": "🐑", "name": "sheep", "keywords": ["animal", "nature", "wool", "shipit"] }, + { "category": "animals_and_nature", "char": "🐎", "name": "racehorse", "keywords": ["animal", "gamble", "luck"] }, + { "category": "animals_and_nature", "char": "🐖", "name": "pig2", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐀", "name": "rat", "keywords": ["animal", "mouse", "rodent"] }, + { "category": "animals_and_nature", "char": "🐁", "name": "mouse2", "keywords": ["animal", "nature", "rodent"] }, + { "category": "animals_and_nature", "char": "🐓", "name": "rooster", "keywords": ["animal", "nature", "chicken"] }, + { "category": "animals_and_nature", "char": "🦃", "name": "turkey", "keywords": ["animal", "bird"] }, + { "category": "animals_and_nature", "char": "🕊", "name": "dove", "keywords": ["animal", "bird"] }, + { "category": "animals_and_nature", "char": "🐕", "name": "dog2", "keywords": ["animal", "nature", "friend", "doge", "pet", "faithful"] }, + { "category": "animals_and_nature", "char": "🐩", "name": "poodle", "keywords": ["dog", "animal", "101", "nature", "pet"] }, + { "category": "animals_and_nature", "char": "🐈", "name": "cat2", "keywords": ["animal", "meow", "pet", "cats"] }, + { "category": "animals_and_nature", "char": "🐇", "name": "rabbit2", "keywords": ["animal", "nature", "pet", "magic", "spring"] }, + { "category": "animals_and_nature", "char": "🐿", "name": "chipmunk", "keywords": ["animal", "nature", "rodent", "squirrel"] }, + { "category": "animals_and_nature", "char": "🦔", "name": "hedgehog", "keywords": ["animal", "nature", "spiny"] }, + { "category": "animals_and_nature", "char": "🦝", "name": "raccoon", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦙", "name": "llama", "keywords": ["animal", "nature", "alpaca"] }, + { "category": "animals_and_nature", "char": "🦛", "name": "hippopotamus", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦘", "name": "kangaroo", "keywords": ["animal", "nature", "australia", "joey", "hop", "marsupial"] }, + { "category": "animals_and_nature", "char": "🦡", "name": "badger", "keywords": ["animal", "nature", "honey"] }, + { "category": "animals_and_nature", "char": "🦢", "name": "swan", "keywords": ["animal", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🦚", "name": "peacock", "keywords": ["animal", "nature", "peahen", "bird"] }, + { "category": "animals_and_nature", "char": "🦜", "name": "parrot", "keywords": ["animal", "nature", "bird", "pirate", "talk"] }, + { "category": "animals_and_nature", "char": "🦞", "name": "lobster", "keywords": ["animal", "nature", "bisque", "claws", "seafood"] }, + { "category": "animals_and_nature", "char": "🦠", "name": "microbe", "keywords": ["amoeba", "bacteria", "germs"] }, + { "category": "animals_and_nature", "char": "🦟", "name": "mosquito", "keywords": ["animal", "nature", "insect", "malaria"] }, + { "category": "animals_and_nature", "char": "🦬", "name": "bison", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦣", "name": "mammoth", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦫", "name": "beaver", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐻❄️", "name": "polar_bear", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] }, + { "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, + { "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, + { "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦮", "name": "guide_dog", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐕🦺", "name": "service_dog", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦥", "name": "sloth", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦦", "name": "otter", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦨", "name": "skunk", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦩", "name": "flamingo", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🌵", "name": "cactus", "keywords": ["vegetable", "plant", "nature"] }, + { "category": "animals_and_nature", "char": "🎄", "name": "christmas_tree", "keywords": ["festival", "vacation", "december", "xmas", "celebration"] }, + { "category": "animals_and_nature", "char": "🌲", "name": "evergreen_tree", "keywords": ["plant", "nature"] }, + { "category": "animals_and_nature", "char": "🌳", "name": "deciduous_tree", "keywords": ["plant", "nature"] }, + { "category": "animals_and_nature", "char": "🌴", "name": "palm_tree", "keywords": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"] }, + { "category": "animals_and_nature", "char": "🌱", "name": "seedling", "keywords": ["plant", "nature", "grass", "lawn", "spring"] }, + { "category": "animals_and_nature", "char": "🌿", "name": "herb", "keywords": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"] }, + { "category": "animals_and_nature", "char": "☘", "name": "shamrock", "keywords": ["vegetable", "plant", "nature", "irish", "clover"] }, + { "category": "animals_and_nature", "char": "🍀", "name": "four_leaf_clover", "keywords": ["vegetable", "plant", "nature", "lucky", "irish"] }, + { "category": "animals_and_nature", "char": "🎍", "name": "bamboo", "keywords": ["plant", "nature", "vegetable", "panda", "pine_decoration"] }, + { "category": "animals_and_nature", "char": "🎋", "name": "tanabata_tree", "keywords": ["plant", "nature", "branch", "summer"] }, + { "category": "animals_and_nature", "char": "🍃", "name": "leaves", "keywords": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"] }, + { "category": "animals_and_nature", "char": "🍂", "name": "fallen_leaf", "keywords": ["nature", "plant", "vegetable", "leaves"] }, + { "category": "animals_and_nature", "char": "🍁", "name": "maple_leaf", "keywords": ["nature", "plant", "vegetable", "ca", "fall"] }, + { "category": "animals_and_nature", "char": "🌾", "name": "ear_of_rice", "keywords": ["nature", "plant"] }, + { "category": "animals_and_nature", "char": "🌺", "name": "hibiscus", "keywords": ["plant", "vegetable", "flowers", "beach"] }, + { "category": "animals_and_nature", "char": "🌻", "name": "sunflower", "keywords": ["nature", "plant", "fall"] }, + { "category": "animals_and_nature", "char": "🌹", "name": "rose", "keywords": ["flowers", "valentines", "love", "spring"] }, + { "category": "animals_and_nature", "char": "🥀", "name": "wilted_flower", "keywords": ["plant", "nature", "flower"] }, + { "category": "animals_and_nature", "char": "🌷", "name": "tulip", "keywords": ["flowers", "plant", "nature", "summer", "spring"] }, + { "category": "animals_and_nature", "char": "🌼", "name": "blossom", "keywords": ["nature", "flowers", "yellow"] }, + { "category": "animals_and_nature", "char": "🌸", "name": "cherry_blossom", "keywords": ["nature", "plant", "spring", "flower"] }, + { "category": "animals_and_nature", "char": "💐", "name": "bouquet", "keywords": ["flowers", "nature", "spring"] }, + { "category": "animals_and_nature", "char": "🍄", "name": "mushroom", "keywords": ["plant", "vegetable"] }, + { "category": "animals_and_nature", "char": "🪴", "name": "potted_plant", "keywords": ["plant"] }, + { "category": "animals_and_nature", "char": "🌰", "name": "chestnut", "keywords": ["food", "squirrel"] }, + { "category": "animals_and_nature", "char": "🎃", "name": "jack_o_lantern", "keywords": ["halloween", "light", "pumpkin", "creepy", "fall"] }, + { "category": "animals_and_nature", "char": "🐚", "name": "shell", "keywords": ["nature", "sea", "beach"] }, + { "category": "animals_and_nature", "char": "🕸", "name": "spider_web", "keywords": ["animal", "insect", "arachnid", "silk"] }, + { "category": "animals_and_nature", "char": "🌎", "name": "earth_americas", "keywords": ["globe", "world", "USA", "international"] }, + { "category": "animals_and_nature", "char": "🌍", "name": "earth_africa", "keywords": ["globe", "world", "international"] }, + { "category": "animals_and_nature", "char": "🌏", "name": "earth_asia", "keywords": ["globe", "world", "east", "international"] }, + { "category": "animals_and_nature", "char": "🪐", "name": "ringed_planet", "keywords": ["saturn"] }, + { "category": "animals_and_nature", "char": "🌕", "name": "full_moon", "keywords": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌖", "name": "waning_gibbous_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"] }, + { "category": "animals_and_nature", "char": "🌗", "name": "last_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌘", "name": "waning_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌑", "name": "new_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌒", "name": "waxing_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌓", "name": "first_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌔", "name": "waxing_gibbous_moon", "keywords": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌚", "name": "new_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌝", "name": "full_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌛", "name": "first_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌜", "name": "last_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌞", "name": "sun_with_face", "keywords": ["nature", "morning", "sky"] }, + { "category": "animals_and_nature", "char": "🌙", "name": "crescent_moon", "keywords": ["night", "sleep", "sky", "evening", "magic"] }, + { "category": "animals_and_nature", "char": "⭐", "name": "star", "keywords": ["night", "yellow"] }, + { "category": "animals_and_nature", "char": "🌟", "name": "star2", "keywords": ["night", "sparkle", "awesome", "good", "magic"] }, + { "category": "animals_and_nature", "char": "💫", "name": "dizzy", "keywords": ["star", "sparkle", "shoot", "magic"] }, + { "category": "animals_and_nature", "char": "✨", "name": "sparkles", "keywords": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"] }, + { "category": "animals_and_nature", "char": "☄", "name": "comet", "keywords": ["space"] }, + { "category": "animals_and_nature", "char": "☀️", "name": "sunny", "keywords": ["weather", "nature", "brightness", "summer", "beach", "spring"] }, + { "category": "animals_and_nature", "char": "🌤", "name": "sun_behind_small_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛅", "name": "partly_sunny", "keywords": ["weather", "nature", "cloudy", "morning", "fall", "spring"] }, + { "category": "animals_and_nature", "char": "🌥", "name": "sun_behind_large_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "🌦", "name": "sun_behind_rain_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "☁️", "name": "cloud", "keywords": ["weather", "sky"] }, + { "category": "animals_and_nature", "char": "🌧", "name": "cloud_with_rain", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛈", "name": "cloud_with_lightning_and_rain", "keywords": ["weather", "lightning"] }, + { "category": "animals_and_nature", "char": "🌩", "name": "cloud_with_lightning", "keywords": ["weather", "thunder"] }, + { "category": "animals_and_nature", "char": "⚡", "name": "zap", "keywords": ["thunder", "weather", "lightning bolt", "fast"] }, + { "category": "animals_and_nature", "char": "🔥", "name": "fire", "keywords": ["hot", "cook", "flame"] }, + { "category": "animals_and_nature", "char": "💥", "name": "boom", "keywords": ["bomb", "explode", "explosion", "collision", "blown"] }, + { "category": "animals_and_nature", "char": "❄️", "name": "snowflake", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas"] }, + { "category": "animals_and_nature", "char": "🌨", "name": "cloud_with_snow", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛄", "name": "snowman", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"] }, + { "category": "animals_and_nature", "char": "☃", "name": "snowman_with_snow", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"] }, + { "category": "animals_and_nature", "char": "🌬", "name": "wind_face", "keywords": ["gust", "air"] }, + { "category": "animals_and_nature", "char": "💨", "name": "dash", "keywords": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"] }, + { "category": "animals_and_nature", "char": "🌪", "name": "tornado", "keywords": ["weather", "cyclone", "twister"] }, + { "category": "animals_and_nature", "char": "🌫", "name": "fog", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "☂", "name": "open_umbrella", "keywords": ["weather", "spring"] }, + { "category": "animals_and_nature", "char": "☔", "name": "umbrella", "keywords": ["rainy", "weather", "spring"] }, + { "category": "animals_and_nature", "char": "💧", "name": "droplet", "keywords": ["water", "drip", "faucet", "spring"] }, + { "category": "animals_and_nature", "char": "💦", "name": "sweat_drops", "keywords": ["water", "drip", "oops"] }, + { "category": "animals_and_nature", "char": "🌊", "name": "ocean", "keywords": ["sea", "water", "wave", "nature", "tsunami", "disaster"] }, + { "category": "food_and_drink", "char": "🍏", "name": "green_apple", "keywords": ["fruit", "nature"] }, + { "category": "food_and_drink", "char": "🍎", "name": "apple", "keywords": ["fruit", "mac", "school"] }, + { "category": "food_and_drink", "char": "🍐", "name": "pear", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍊", "name": "tangerine", "keywords": ["food", "fruit", "nature", "orange"] }, + { "category": "food_and_drink", "char": "🍋", "name": "lemon", "keywords": ["fruit", "nature"] }, + { "category": "food_and_drink", "char": "🍌", "name": "banana", "keywords": ["fruit", "food", "monkey"] }, + { "category": "food_and_drink", "char": "🍉", "name": "watermelon", "keywords": ["fruit", "food", "picnic", "summer"] }, + { "category": "food_and_drink", "char": "🍇", "name": "grapes", "keywords": ["fruit", "food", "wine"] }, + { "category": "food_and_drink", "char": "🍓", "name": "strawberry", "keywords": ["fruit", "food", "nature"] }, + { "category": "food_and_drink", "char": "🍈", "name": "melon", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍒", "name": "cherries", "keywords": ["food", "fruit"] }, + { "category": "food_and_drink", "char": "🍑", "name": "peach", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍍", "name": "pineapple", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🥥", "name": "coconut", "keywords": ["fruit", "nature", "food", "palm"] }, + { "category": "food_and_drink", "char": "🥝", "name": "kiwi_fruit", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥭", "name": "mango", "keywords": ["fruit", "food", "tropical"] }, + { "category": "food_and_drink", "char": "🥑", "name": "avocado", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥦", "name": "broccoli", "keywords": ["fruit", "food", "vegetable"] }, + { "category": "food_and_drink", "char": "🍅", "name": "tomato", "keywords": ["fruit", "vegetable", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍆", "name": "eggplant", "keywords": ["vegetable", "nature", "food", "aubergine"] }, + { "category": "food_and_drink", "char": "🥒", "name": "cucumber", "keywords": ["fruit", "food", "pickle"] }, + { "category": "food_and_drink", "char": "🫐", "name": "blueberries", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🫒", "name": "olive", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🫑", "name": "bell_pepper", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥕", "name": "carrot", "keywords": ["vegetable", "food", "orange"] }, + { "category": "food_and_drink", "char": "🌶", "name": "hot_pepper", "keywords": ["food", "spicy", "chilli", "chili"] }, + { "category": "food_and_drink", "char": "🥔", "name": "potato", "keywords": ["food", "tuber", "vegatable", "starch"] }, + { "category": "food_and_drink", "char": "🌽", "name": "corn", "keywords": ["food", "vegetable", "plant"] }, + { "category": "food_and_drink", "char": "🥬", "name": "leafy_greens", "keywords": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"] }, + { "category": "food_and_drink", "char": "🍠", "name": "sweet_potato", "keywords": ["food", "nature"] }, + { "category": "food_and_drink", "char": "🥜", "name": "peanuts", "keywords": ["food", "nut"] }, + { "category": "food_and_drink", "char": "🧄", "name": "garlic", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧅", "name": "onion", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🍯", "name": "honey_pot", "keywords": ["bees", "sweet", "kitchen"] }, + { "category": "food_and_drink", "char": "🥐", "name": "croissant", "keywords": ["food", "bread", "french"] }, + { "category": "food_and_drink", "char": "🍞", "name": "bread", "keywords": ["food", "wheat", "breakfast", "toast"] }, + { "category": "food_and_drink", "char": "🥖", "name": "baguette_bread", "keywords": ["food", "bread", "french"] }, + { "category": "food_and_drink", "char": "🥯", "name": "bagel", "keywords": ["food", "bread", "bakery", "schmear"] }, + { "category": "food_and_drink", "char": "🥨", "name": "pretzel", "keywords": ["food", "bread", "twisted"] }, + { "category": "food_and_drink", "char": "🧀", "name": "cheese", "keywords": ["food", "chadder"] }, + { "category": "food_and_drink", "char": "🥚", "name": "egg", "keywords": ["food", "chicken", "breakfast"] }, + { "category": "food_and_drink", "char": "🥓", "name": "bacon", "keywords": ["food", "breakfast", "pork", "pig", "meat"] }, + { "category": "food_and_drink", "char": "🥩", "name": "steak", "keywords": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"] }, + { "category": "food_and_drink", "char": "🥞", "name": "pancakes", "keywords": ["food", "breakfast", "flapjacks", "hotcakes"] }, + { "category": "food_and_drink", "char": "🍗", "name": "poultry_leg", "keywords": ["food", "meat", "drumstick", "bird", "chicken", "turkey"] }, + { "category": "food_and_drink", "char": "🍖", "name": "meat_on_bone", "keywords": ["good", "food", "drumstick"] }, + { "category": "food_and_drink", "char": "🦴", "name": "bone", "keywords": ["skeleton"] }, + { "category": "food_and_drink", "char": "🍤", "name": "fried_shrimp", "keywords": ["food", "animal", "appetizer", "summer"] }, + { "category": "food_and_drink", "char": "🍳", "name": "fried_egg", "keywords": ["food", "breakfast", "kitchen", "egg"] }, + { "category": "food_and_drink", "char": "🍔", "name": "hamburger", "keywords": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"] }, + { "category": "food_and_drink", "char": "🍟", "name": "fries", "keywords": ["chips", "snack", "fast food"] }, + { "category": "food_and_drink", "char": "🥙", "name": "stuffed_flatbread", "keywords": ["food", "flatbread", "stuffed", "gyro"] }, + { "category": "food_and_drink", "char": "🌭", "name": "hotdog", "keywords": ["food", "frankfurter"] }, + { "category": "food_and_drink", "char": "🍕", "name": "pizza", "keywords": ["food", "party"] }, + { "category": "food_and_drink", "char": "🥪", "name": "sandwich", "keywords": ["food", "lunch", "bread"] }, + { "category": "food_and_drink", "char": "🥫", "name": "canned_food", "keywords": ["food", "soup"] }, + { "category": "food_and_drink", "char": "🍝", "name": "spaghetti", "keywords": ["food", "italian", "noodle"] }, + { "category": "food_and_drink", "char": "🌮", "name": "taco", "keywords": ["food", "mexican"] }, + { "category": "food_and_drink", "char": "🌯", "name": "burrito", "keywords": ["food", "mexican"] }, + { "category": "food_and_drink", "char": "🥗", "name": "green_salad", "keywords": ["food", "healthy", "lettuce"] }, + { "category": "food_and_drink", "char": "🥘", "name": "shallow_pan_of_food", "keywords": ["food", "cooking", "casserole", "paella"] }, + { "category": "food_and_drink", "char": "🍜", "name": "ramen", "keywords": ["food", "japanese", "noodle", "chopsticks"] }, + { "category": "food_and_drink", "char": "🍲", "name": "stew", "keywords": ["food", "meat", "soup"] }, + { "category": "food_and_drink", "char": "🍥", "name": "fish_cake", "keywords": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"] }, + { "category": "food_and_drink", "char": "🥠", "name": "fortune_cookie", "keywords": ["food", "prophecy"] }, + { "category": "food_and_drink", "char": "🍣", "name": "sushi", "keywords": ["food", "fish", "japanese", "rice"] }, + { "category": "food_and_drink", "char": "🍱", "name": "bento", "keywords": ["food", "japanese", "box"] }, + { "category": "food_and_drink", "char": "🍛", "name": "curry", "keywords": ["food", "spicy", "hot", "indian"] }, + { "category": "food_and_drink", "char": "🍙", "name": "rice_ball", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍚", "name": "rice", "keywords": ["food", "china", "asian"] }, + { "category": "food_and_drink", "char": "🍘", "name": "rice_cracker", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍢", "name": "oden", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍡", "name": "dango", "keywords": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"] }, + { "category": "food_and_drink", "char": "🍧", "name": "shaved_ice", "keywords": ["hot", "dessert", "summer"] }, + { "category": "food_and_drink", "char": "🍨", "name": "ice_cream", "keywords": ["food", "hot", "dessert"] }, + { "category": "food_and_drink", "char": "🍦", "name": "icecream", "keywords": ["food", "hot", "dessert", "summer"] }, + { "category": "food_and_drink", "char": "🥧", "name": "pie", "keywords": ["food", "dessert", "pastry"] }, + { "category": "food_and_drink", "char": "🍰", "name": "cake", "keywords": ["food", "dessert"] }, + { "category": "food_and_drink", "char": "🧁", "name": "cupcake", "keywords": ["food", "dessert", "bakery", "sweet"] }, + { "category": "food_and_drink", "char": "🥮", "name": "moon_cake", "keywords": ["food", "autumn"] }, + { "category": "food_and_drink", "char": "🎂", "name": "birthday", "keywords": ["food", "dessert", "cake"] }, + { "category": "food_and_drink", "char": "🍮", "name": "custard", "keywords": ["dessert", "food"] }, + { "category": "food_and_drink", "char": "🍬", "name": "candy", "keywords": ["snack", "dessert", "sweet", "lolly"] }, + { "category": "food_and_drink", "char": "🍭", "name": "lollipop", "keywords": ["food", "snack", "candy", "sweet"] }, + { "category": "food_and_drink", "char": "🍫", "name": "chocolate_bar", "keywords": ["food", "snack", "dessert", "sweet"] }, + { "category": "food_and_drink", "char": "🍿", "name": "popcorn", "keywords": ["food", "movie theater", "films", "snack"] }, + { "category": "food_and_drink", "char": "🥟", "name": "dumpling", "keywords": ["food", "empanada", "pierogi", "potsticker"] }, + { "category": "food_and_drink", "char": "🍩", "name": "doughnut", "keywords": ["food", "dessert", "snack", "sweet", "donut"] }, + { "category": "food_and_drink", "char": "🍪", "name": "cookie", "keywords": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"] }, + { "category": "food_and_drink", "char": "🧇", "name": "waffle", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧆", "name": "falafel", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧈", "name": "butter", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🦪", "name": "oyster", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫓", "name": "flatbread", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫔", "name": "tamale", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫕", "name": "fondue", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🥛", "name": "milk_glass", "keywords": ["beverage", "drink", "cow"] }, + { "category": "food_and_drink", "char": "🍺", "name": "beer", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🍻", "name": "beers", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🥂", "name": "clinking_glasses", "keywords": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"] }, + { "category": "food_and_drink", "char": "🍷", "name": "wine_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🥃", "name": "tumbler_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"] }, + { "category": "food_and_drink", "char": "🍸", "name": "cocktail", "keywords": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"] }, + { "category": "food_and_drink", "char": "🍹", "name": "tropical_drink", "keywords": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"] }, + { "category": "food_and_drink", "char": "🍾", "name": "champagne", "keywords": ["drink", "wine", "bottle", "celebration"] }, + { "category": "food_and_drink", "char": "🍶", "name": "sake", "keywords": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🍵", "name": "tea", "keywords": ["drink", "bowl", "breakfast", "green", "british"] }, + { "category": "food_and_drink", "char": "🥤", "name": "cup_with_straw", "keywords": ["drink", "soda"] }, + { "category": "food_and_drink", "char": "☕", "name": "coffee", "keywords": ["beverage", "caffeine", "latte", "espresso"] }, + { "category": "food_and_drink", "char": "🫖", "name": "teapot", "keywords": [] }, + { "category": "food_and_drink", "char": "🧋", "name": "bubble_tea", "keywords": ["tapioca"] }, + { "category": "food_and_drink", "char": "🍼", "name": "baby_bottle", "keywords": ["food", "container", "milk"] }, + { "category": "food_and_drink", "char": "🧃", "name": "beverage_box", "keywords": ["food", "drink"] }, + { "category": "food_and_drink", "char": "🧉", "name": "mate", "keywords": ["food", "drink"] }, + { "category": "food_and_drink", "char": "🧊", "name": "ice_cube", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧂", "name": "salt", "keywords": ["condiment", "shaker"] }, + { "category": "food_and_drink", "char": "🥄", "name": "spoon", "keywords": ["cutlery", "kitchen", "tableware"] }, + { "category": "food_and_drink", "char": "🍴", "name": "fork_and_knife", "keywords": ["cutlery", "kitchen"] }, + { "category": "food_and_drink", "char": "🍽", "name": "plate_with_cutlery", "keywords": ["food", "eat", "meal", "lunch", "dinner", "restaurant"] }, + { "category": "food_and_drink", "char": "🥣", "name": "bowl_with_spoon", "keywords": ["food", "breakfast", "cereal", "oatmeal", "porridge"] }, + { "category": "food_and_drink", "char": "🥡", "name": "takeout_box", "keywords": ["food", "leftovers"] }, + { "category": "food_and_drink", "char": "🥢", "name": "chopsticks", "keywords": ["food"] }, + { "category": "activity", "char": "⚽", "name": "soccer", "keywords": ["sports", "football"] }, + { "category": "activity", "char": "🏀", "name": "basketball", "keywords": ["sports", "balls", "NBA"] }, + { "category": "activity", "char": "🏈", "name": "football", "keywords": ["sports", "balls", "NFL"] }, + { "category": "activity", "char": "⚾", "name": "baseball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🥎", "name": "softball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🎾", "name": "tennis", "keywords": ["sports", "balls", "green"] }, + { "category": "activity", "char": "🏐", "name": "volleyball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🏉", "name": "rugby_football", "keywords": ["sports", "team"] }, + { "category": "activity", "char": "🥏", "name": "flying_disc", "keywords": ["sports", "frisbee", "ultimate"] }, + { "category": "activity", "char": "🎱", "name": "8ball", "keywords": ["pool", "hobby", "game", "luck", "magic"] }, + { "category": "activity", "char": "⛳", "name": "golf", "keywords": ["sports", "business", "flag", "hole", "summer"] }, + { "category": "activity", "char": "🏌️♀️", "name": "golfing_woman", "keywords": ["sports", "business", "woman", "female"] }, + { "category": "activity", "char": "🏌", "name": "golfing_man", "keywords": ["sports", "business"] }, + { "category": "activity", "char": "🏓", "name": "ping_pong", "keywords": ["sports", "pingpong"] }, + { "category": "activity", "char": "🏸", "name": "badminton", "keywords": ["sports"] }, + { "category": "activity", "char": "🥅", "name": "goal_net", "keywords": ["sports"] }, + { "category": "activity", "char": "🏒", "name": "ice_hockey", "keywords": ["sports"] }, + { "category": "activity", "char": "🏑", "name": "field_hockey", "keywords": ["sports"] }, + { "category": "activity", "char": "🥍", "name": "lacrosse", "keywords": ["sports", "ball", "stick"] }, + { "category": "activity", "char": "🏏", "name": "cricket", "keywords": ["sports"] }, + { "category": "activity", "char": "🎿", "name": "ski", "keywords": ["sports", "winter", "cold", "snow"] }, + { "category": "activity", "char": "⛷", "name": "skier", "keywords": ["sports", "winter", "snow"] }, + { "category": "activity", "char": "🏂", "name": "snowboarder", "keywords": ["sports", "winter"] }, + { "category": "activity", "char": "🤺", "name": "person_fencing", "keywords": ["sports", "fencing", "sword"] }, + { "category": "activity", "char": "🤼♀️", "name": "women_wrestling", "keywords": ["sports", "wrestlers"] }, + { "category": "activity", "char": "🤼♂️", "name": "men_wrestling", "keywords": ["sports", "wrestlers"] }, + { "category": "activity", "char": "🤸♀️", "name": "woman_cartwheeling", "keywords": ["gymnastics"] }, + { "category": "activity", "char": "🤸♂️", "name": "man_cartwheeling", "keywords": ["gymnastics"] }, + { "category": "activity", "char": "🤾♀️", "name": "woman_playing_handball", "keywords": ["sports"] }, + { "category": "activity", "char": "🤾♂️", "name": "man_playing_handball", "keywords": ["sports"] }, + { "category": "activity", "char": "⛸", "name": "ice_skate", "keywords": ["sports"] }, + { "category": "activity", "char": "🥌", "name": "curling_stone", "keywords": ["sports"] }, + { "category": "activity", "char": "🛹", "name": "skateboard", "keywords": ["board"] }, + { "category": "activity", "char": "🛷", "name": "sled", "keywords": ["sleigh", "luge", "toboggan"] }, + { "category": "activity", "char": "🏹", "name": "bow_and_arrow", "keywords": ["sports"] }, + { "category": "activity", "char": "🎣", "name": "fishing_pole_and_fish", "keywords": ["food", "hobby", "summer"] }, + { "category": "activity", "char": "🥊", "name": "boxing_glove", "keywords": ["sports", "fighting"] }, + { "category": "activity", "char": "🥋", "name": "martial_arts_uniform", "keywords": ["judo", "karate", "taekwondo"] }, + { "category": "activity", "char": "🚣♀️", "name": "rowing_woman", "keywords": ["sports", "hobby", "water", "ship", "woman", "female"] }, + { "category": "activity", "char": "🚣", "name": "rowing_man", "keywords": ["sports", "hobby", "water", "ship"] }, + { "category": "activity", "char": "🧗♀️", "name": "climbing_woman", "keywords": ["sports", "hobby", "woman", "female", "rock"] }, + { "category": "activity", "char": "🧗♂️", "name": "climbing_man", "keywords": ["sports", "hobby", "man", "male", "rock"] }, + { "category": "activity", "char": "🏊♀️", "name": "swimming_woman", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"] }, + { "category": "activity", "char": "🏊", "name": "swimming_man", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer"] }, + { "category": "activity", "char": "🤽♀️", "name": "woman_playing_water_polo", "keywords": ["sports", "pool"] }, + { "category": "activity", "char": "🤽♂️", "name": "man_playing_water_polo", "keywords": ["sports", "pool"] }, + { "category": "activity", "char": "🧘♀️", "name": "woman_in_lotus_position", "keywords": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, + { "category": "activity", "char": "🧘♂️", "name": "man_in_lotus_position", "keywords": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, + { "category": "activity", "char": "🏄♀️", "name": "surfing_woman", "keywords": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"] }, + { "category": "activity", "char": "🏄", "name": "surfing_man", "keywords": ["sports", "ocean", "sea", "summer", "beach"] }, + { "category": "activity", "char": "🛀", "name": "bath", "keywords": ["clean", "shower", "bathroom"] }, + { "category": "activity", "char": "⛹️♀️", "name": "basketball_woman", "keywords": ["sports", "human", "woman", "female"] }, + { "category": "activity", "char": "⛹", "name": "basketball_man", "keywords": ["sports", "human"] }, + { "category": "activity", "char": "🏋️♀️", "name": "weight_lifting_woman", "keywords": ["sports", "training", "exercise", "woman", "female"] }, + { "category": "activity", "char": "🏋", "name": "weight_lifting_man", "keywords": ["sports", "training", "exercise"] }, + { "category": "activity", "char": "🚴♀️", "name": "biking_woman", "keywords": ["sports", "bike", "exercise", "hipster", "woman", "female"] }, + { "category": "activity", "char": "🚴", "name": "biking_man", "keywords": ["sports", "bike", "exercise", "hipster"] }, + { "category": "activity", "char": "🚵♀️", "name": "mountain_biking_woman", "keywords": ["transportation", "sports", "human", "race", "bike", "woman", "female"] }, + { "category": "activity", "char": "🚵", "name": "mountain_biking_man", "keywords": ["transportation", "sports", "human", "race", "bike"] }, + { "category": "activity", "char": "🏇", "name": "horse_racing", "keywords": ["animal", "betting", "competition", "gambling", "luck"] }, + { "category": "activity", "char": "🤿", "name": "diving_mask", "keywords": ["sports"] }, + { "category": "activity", "char": "🪀", "name": "yo_yo", "keywords": ["sports"] }, + { "category": "activity", "char": "🪁", "name": "kite", "keywords": ["sports"] }, + { "category": "activity", "char": "🦺", "name": "safety_vest", "keywords": ["sports"] }, + { "category": "activity", "char": "🪡", "name": "sewing_needle", "keywords": [] }, + { "category": "activity", "char": "🪢", "name": "knot", "keywords": [] }, + { "category": "activity", "char": "🕴", "name": "business_suit_levitating", "keywords": ["suit", "business", "levitate", "hover", "jump"] }, + { "category": "activity", "char": "🏆", "name": "trophy", "keywords": ["win", "award", "contest", "place", "ftw", "ceremony"] }, + { "category": "activity", "char": "🎽", "name": "running_shirt_with_sash", "keywords": ["play", "pageant"] }, + { "category": "activity", "char": "🏅", "name": "medal_sports", "keywords": ["award", "winning"] }, + { "category": "activity", "char": "🎖", "name": "medal_military", "keywords": ["award", "winning", "army"] }, + { "category": "activity", "char": "🥇", "name": "1st_place_medal", "keywords": ["award", "winning", "first"] }, + { "category": "activity", "char": "🥈", "name": "2nd_place_medal", "keywords": ["award", "second"] }, + { "category": "activity", "char": "🥉", "name": "3rd_place_medal", "keywords": ["award", "third"] }, + { "category": "activity", "char": "🎗", "name": "reminder_ribbon", "keywords": ["sports", "cause", "support", "awareness"] }, + { "category": "activity", "char": "🏵", "name": "rosette", "keywords": ["flower", "decoration", "military"] }, + { "category": "activity", "char": "🎫", "name": "ticket", "keywords": ["event", "concert", "pass"] }, + { "category": "activity", "char": "🎟", "name": "tickets", "keywords": ["sports", "concert", "entrance"] }, + { "category": "activity", "char": "🎭", "name": "performing_arts", "keywords": ["acting", "theater", "drama"] }, + { "category": "activity", "char": "🎨", "name": "art", "keywords": ["design", "paint", "draw", "colors"] }, + { "category": "activity", "char": "🎪", "name": "circus_tent", "keywords": ["festival", "carnival", "party"] }, + { "category": "activity", "char": "🤹♀️", "name": "woman_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, + { "category": "activity", "char": "🤹♂️", "name": "man_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, + { "category": "activity", "char": "🎤", "name": "microphone", "keywords": ["sound", "music", "PA", "sing", "talkshow"] }, + { "category": "activity", "char": "🎧", "name": "headphones", "keywords": ["music", "score", "gadgets"] }, + { "category": "activity", "char": "🎼", "name": "musical_score", "keywords": ["treble", "clef", "compose"] }, + { "category": "activity", "char": "🎹", "name": "musical_keyboard", "keywords": ["piano", "instrument", "compose"] }, + { "category": "activity", "char": "🥁", "name": "drum", "keywords": ["music", "instrument", "drumsticks", "snare"] }, + { "category": "activity", "char": "🎷", "name": "saxophone", "keywords": ["music", "instrument", "jazz", "blues"] }, + { "category": "activity", "char": "🎺", "name": "trumpet", "keywords": ["music", "brass"] }, + { "category": "activity", "char": "🎸", "name": "guitar", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🎻", "name": "violin", "keywords": ["music", "instrument", "orchestra", "symphony"] }, + { "category": "activity", "char": "🪕", "name": "banjo", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🪗", "name": "accordion", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🪘", "name": "long_drum", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🎬", "name": "clapper", "keywords": ["movie", "film", "record"] }, + { "category": "activity", "char": "🎮", "name": "video_game", "keywords": ["play", "console", "PS4", "controller"] }, + { "category": "activity", "char": "👾", "name": "space_invader", "keywords": ["game", "arcade", "play"] }, + { "category": "activity", "char": "🎯", "name": "dart", "keywords": ["game", "play", "bar", "target", "bullseye"] }, + { "category": "activity", "char": "🎲", "name": "game_die", "keywords": ["dice", "random", "tabletop", "play", "luck"] }, + { "category": "activity", "char": "♟️", "name": "chess_pawn", "keywords": ["expendable"] }, + { "category": "activity", "char": "🎰", "name": "slot_machine", "keywords": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"] }, + { "category": "activity", "char": "🧩", "name": "jigsaw", "keywords": ["interlocking", "puzzle", "piece"] }, + { "category": "activity", "char": "🎳", "name": "bowling", "keywords": ["sports", "fun", "play"] }, + { "category": "activity", "char": "🪄", "name": "magic_wand", "keywords": [] }, + { "category": "activity", "char": "🪅", "name": "pinata", "keywords": [] }, + { "category": "activity", "char": "🪆", "name": "nesting_dolls", "keywords": [] }, + { "category": "travel_and_places", "char": "🚗", "name": "red_car", "keywords": ["red", "transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚕", "name": "taxi", "keywords": ["uber", "vehicle", "cars", "transportation"] }, + { "category": "travel_and_places", "char": "🚙", "name": "blue_car", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚌", "name": "bus", "keywords": ["car", "vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚎", "name": "trolleybus", "keywords": ["bart", "transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🏎", "name": "racing_car", "keywords": ["sports", "race", "fast", "formula", "f1"] }, + { "category": "travel_and_places", "char": "🚓", "name": "police_car", "keywords": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"] }, + { "category": "travel_and_places", "char": "🚑", "name": "ambulance", "keywords": ["health", "911", "hospital"] }, + { "category": "travel_and_places", "char": "🚒", "name": "fire_engine", "keywords": ["transportation", "cars", "vehicle"] }, + { "category": "travel_and_places", "char": "🚐", "name": "minibus", "keywords": ["vehicle", "car", "transportation"] }, + { "category": "travel_and_places", "char": "🚚", "name": "truck", "keywords": ["cars", "transportation"] }, + { "category": "travel_and_places", "char": "🚛", "name": "articulated_lorry", "keywords": ["vehicle", "cars", "transportation", "express"] }, + { "category": "travel_and_places", "char": "🚜", "name": "tractor", "keywords": ["vehicle", "car", "farming", "agriculture"] }, + { "category": "travel_and_places", "char": "🛴", "name": "kick_scooter", "keywords": ["vehicle", "kick", "razor"] }, + { "category": "travel_and_places", "char": "🏍", "name": "motorcycle", "keywords": ["race", "sports", "fast"] }, + { "category": "travel_and_places", "char": "🚲", "name": "bike", "keywords": ["sports", "bicycle", "exercise", "hipster"] }, + { "category": "travel_and_places", "char": "🛵", "name": "motor_scooter", "keywords": ["vehicle", "vespa", "sasha"] }, + { "category": "travel_and_places", "char": "🦽", "name": "manual_wheelchair", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🦼", "name": "motorized_wheelchair", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🛺", "name": "auto_rickshaw", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🪂", "name": "parachute", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🚨", "name": "rotating_light", "keywords": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"] }, + { "category": "travel_and_places", "char": "🚔", "name": "oncoming_police_car", "keywords": ["vehicle", "law", "legal", "enforcement", "911"] }, + { "category": "travel_and_places", "char": "🚍", "name": "oncoming_bus", "keywords": ["vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚘", "name": "oncoming_automobile", "keywords": ["car", "vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚖", "name": "oncoming_taxi", "keywords": ["vehicle", "cars", "uber"] }, + { "category": "travel_and_places", "char": "🚡", "name": "aerial_tramway", "keywords": ["transportation", "vehicle", "ski"] }, + { "category": "travel_and_places", "char": "🚠", "name": "mountain_cableway", "keywords": ["transportation", "vehicle", "ski"] }, + { "category": "travel_and_places", "char": "🚟", "name": "suspension_railway", "keywords": ["vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚃", "name": "railway_car", "keywords": ["transportation", "vehicle", "train"] }, + { "category": "travel_and_places", "char": "🚋", "name": "train", "keywords": ["transportation", "vehicle", "carriage", "public", "travel"] }, + { "category": "travel_and_places", "char": "🚝", "name": "monorail", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚄", "name": "bullettrain_side", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚅", "name": "bullettrain_front", "keywords": ["transportation", "vehicle", "speed", "fast", "public", "travel"] }, + { "category": "travel_and_places", "char": "🚈", "name": "light_rail", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚞", "name": "mountain_railway", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚂", "name": "steam_locomotive", "keywords": ["transportation", "vehicle", "train"] }, + { "category": "travel_and_places", "char": "🚆", "name": "train2", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚇", "name": "metro", "keywords": ["transportation", "blue-square", "mrt", "underground", "tube"] }, + { "category": "travel_and_places", "char": "🚊", "name": "tram", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚉", "name": "station", "keywords": ["transportation", "vehicle", "public"] }, + { "category": "travel_and_places", "char": "🛸", "name": "flying_saucer", "keywords": ["transportation", "vehicle", "ufo"] }, + { "category": "travel_and_places", "char": "🚁", "name": "helicopter", "keywords": ["transportation", "vehicle", "fly"] }, + { "category": "travel_and_places", "char": "🛩", "name": "small_airplane", "keywords": ["flight", "transportation", "fly", "vehicle"] }, + { "category": "travel_and_places", "char": "✈️", "name": "airplane", "keywords": ["vehicle", "transportation", "flight", "fly"] }, + { "category": "travel_and_places", "char": "🛫", "name": "flight_departure", "keywords": ["airport", "flight", "landing"] }, + { "category": "travel_and_places", "char": "🛬", "name": "flight_arrival", "keywords": ["airport", "flight", "boarding"] }, + { "category": "travel_and_places", "char": "⛵", "name": "sailboat", "keywords": ["ship", "summer", "transportation", "water", "sailing"] }, + { "category": "travel_and_places", "char": "🛥", "name": "motor_boat", "keywords": ["ship"] }, + { "category": "travel_and_places", "char": "🚤", "name": "speedboat", "keywords": ["ship", "transportation", "vehicle", "summer"] }, + { "category": "travel_and_places", "char": "⛴", "name": "ferry", "keywords": ["boat", "ship", "yacht"] }, + { "category": "travel_and_places", "char": "🛳", "name": "passenger_ship", "keywords": ["yacht", "cruise", "ferry"] }, + { "category": "travel_and_places", "char": "🚀", "name": "rocket", "keywords": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"] }, + { "category": "travel_and_places", "char": "🛰", "name": "artificial_satellite", "keywords": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"] }, + { "category": "travel_and_places", "char": "🛻", "name": "pickup_truck", "keywords": ["car"] }, + { "category": "travel_and_places", "char": "🛼", "name": "roller_skate", "keywords": [] }, + { "category": "travel_and_places", "char": "💺", "name": "seat", "keywords": ["sit", "airplane", "transport", "bus", "flight", "fly"] }, + { "category": "travel_and_places", "char": "🛶", "name": "canoe", "keywords": ["boat", "paddle", "water", "ship"] }, + { "category": "travel_and_places", "char": "⚓", "name": "anchor", "keywords": ["ship", "ferry", "sea", "boat"] }, + { "category": "travel_and_places", "char": "🚧", "name": "construction", "keywords": ["wip", "progress", "caution", "warning"] }, + { "category": "travel_and_places", "char": "⛽", "name": "fuelpump", "keywords": ["gas station", "petroleum"] }, + { "category": "travel_and_places", "char": "🚏", "name": "busstop", "keywords": ["transportation", "wait"] }, + { "category": "travel_and_places", "char": "🚦", "name": "vertical_traffic_light", "keywords": ["transportation", "driving"] }, + { "category": "travel_and_places", "char": "🚥", "name": "traffic_light", "keywords": ["transportation", "signal"] }, + { "category": "travel_and_places", "char": "🏁", "name": "checkered_flag", "keywords": ["contest", "finishline", "race", "gokart"] }, + { "category": "travel_and_places", "char": "🚢", "name": "ship", "keywords": ["transportation", "titanic", "deploy"] }, + { "category": "travel_and_places", "char": "🎡", "name": "ferris_wheel", "keywords": ["photo", "carnival", "londoneye"] }, + { "category": "travel_and_places", "char": "🎢", "name": "roller_coaster", "keywords": ["carnival", "playground", "photo", "fun"] }, + { "category": "travel_and_places", "char": "🎠", "name": "carousel_horse", "keywords": ["photo", "carnival"] }, + { "category": "travel_and_places", "char": "🏗", "name": "building_construction", "keywords": ["wip", "working", "progress"] }, + { "category": "travel_and_places", "char": "🌁", "name": "foggy", "keywords": ["photo", "mountain"] }, + { "category": "travel_and_places", "char": "🏭", "name": "factory", "keywords": ["building", "industry", "pollution", "smoke"] }, + { "category": "travel_and_places", "char": "⛲", "name": "fountain", "keywords": ["photo", "summer", "water", "fresh"] }, + { "category": "travel_and_places", "char": "🎑", "name": "rice_scene", "keywords": ["photo", "japan", "asia", "tsukimi"] }, + { "category": "travel_and_places", "char": "⛰", "name": "mountain", "keywords": ["photo", "nature", "environment"] }, + { "category": "travel_and_places", "char": "🏔", "name": "mountain_snow", "keywords": ["photo", "nature", "environment", "winter", "cold"] }, + { "category": "travel_and_places", "char": "🗻", "name": "mount_fuji", "keywords": ["photo", "mountain", "nature", "japanese"] }, + { "category": "travel_and_places", "char": "🌋", "name": "volcano", "keywords": ["photo", "nature", "disaster"] }, + { "category": "travel_and_places", "char": "🗾", "name": "japan", "keywords": ["nation", "country", "japanese", "asia"] }, + { "category": "travel_and_places", "char": "🏕", "name": "camping", "keywords": ["photo", "outdoors", "tent"] }, + { "category": "travel_and_places", "char": "⛺", "name": "tent", "keywords": ["photo", "camping", "outdoors"] }, + { "category": "travel_and_places", "char": "🏞", "name": "national_park", "keywords": ["photo", "environment", "nature"] }, + { "category": "travel_and_places", "char": "🛣", "name": "motorway", "keywords": ["road", "cupertino", "interstate", "highway"] }, + { "category": "travel_and_places", "char": "🛤", "name": "railway_track", "keywords": ["train", "transportation"] }, + { "category": "travel_and_places", "char": "🌅", "name": "sunrise", "keywords": ["morning", "view", "vacation", "photo"] }, + { "category": "travel_and_places", "char": "🌄", "name": "sunrise_over_mountains", "keywords": ["view", "vacation", "photo"] }, + { "category": "travel_and_places", "char": "🏜", "name": "desert", "keywords": ["photo", "warm", "saharah"] }, + { "category": "travel_and_places", "char": "🏖", "name": "beach_umbrella", "keywords": ["weather", "summer", "sunny", "sand", "mojito"] }, + { "category": "travel_and_places", "char": "🏝", "name": "desert_island", "keywords": ["photo", "tropical", "mojito"] }, + { "category": "travel_and_places", "char": "🌇", "name": "city_sunrise", "keywords": ["photo", "good morning", "dawn"] }, + { "category": "travel_and_places", "char": "🌆", "name": "city_sunset", "keywords": ["photo", "evening", "sky", "buildings"] }, + { "category": "travel_and_places", "char": "🏙", "name": "cityscape", "keywords": ["photo", "night life", "urban"] }, + { "category": "travel_and_places", "char": "🌃", "name": "night_with_stars", "keywords": ["evening", "city", "downtown"] }, + { "category": "travel_and_places", "char": "🌉", "name": "bridge_at_night", "keywords": ["photo", "sanfrancisco"] }, + { "category": "travel_and_places", "char": "🌌", "name": "milky_way", "keywords": ["photo", "space", "stars"] }, + { "category": "travel_and_places", "char": "🌠", "name": "stars", "keywords": ["night", "photo"] }, + { "category": "travel_and_places", "char": "🎇", "name": "sparkler", "keywords": ["stars", "night", "shine"] }, + { "category": "travel_and_places", "char": "🎆", "name": "fireworks", "keywords": ["photo", "festival", "carnival", "congratulations"] }, + { "category": "travel_and_places", "char": "🌈", "name": "rainbow", "keywords": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"] }, + { "category": "travel_and_places", "char": "🏘", "name": "houses", "keywords": ["buildings", "photo"] }, + { "category": "travel_and_places", "char": "🏰", "name": "european_castle", "keywords": ["building", "royalty", "history"] }, + { "category": "travel_and_places", "char": "🏯", "name": "japanese_castle", "keywords": ["photo", "building"] }, + { "category": "travel_and_places", "char": "🗼", "name": "tokyo_tower", "keywords": ["photo", "japanese"] }, + { "category": "travel_and_places", "char": "", "name": "shibuya_109", "keywords": ["photo", "japanese"] }, + { "category": "travel_and_places", "char": "🏟", "name": "stadium", "keywords": ["photo", "place", "sports", "concert", "venue"] }, + { "category": "travel_and_places", "char": "🗽", "name": "statue_of_liberty", "keywords": ["american", "newyork"] }, + { "category": "travel_and_places", "char": "🏠", "name": "house", "keywords": ["building", "home"] }, + { "category": "travel_and_places", "char": "🏡", "name": "house_with_garden", "keywords": ["home", "plant", "nature"] }, + { "category": "travel_and_places", "char": "🏚", "name": "derelict_house", "keywords": ["abandon", "evict", "broken", "building"] }, + { "category": "travel_and_places", "char": "🏢", "name": "office", "keywords": ["building", "bureau", "work"] }, + { "category": "travel_and_places", "char": "🏬", "name": "department_store", "keywords": ["building", "shopping", "mall"] }, + { "category": "travel_and_places", "char": "🏣", "name": "post_office", "keywords": ["building", "envelope", "communication"] }, + { "category": "travel_and_places", "char": "🏤", "name": "european_post_office", "keywords": ["building", "email"] }, + { "category": "travel_and_places", "char": "🏥", "name": "hospital", "keywords": ["building", "health", "surgery", "doctor"] }, + { "category": "travel_and_places", "char": "🏦", "name": "bank", "keywords": ["building", "money", "sales", "cash", "business", "enterprise"] }, + { "category": "travel_and_places", "char": "🏨", "name": "hotel", "keywords": ["building", "accomodation", "checkin"] }, + { "category": "travel_and_places", "char": "🏪", "name": "convenience_store", "keywords": ["building", "shopping", "groceries"] }, + { "category": "travel_and_places", "char": "🏫", "name": "school", "keywords": ["building", "student", "education", "learn", "teach"] }, + { "category": "travel_and_places", "char": "🏩", "name": "love_hotel", "keywords": ["like", "affection", "dating"] }, + { "category": "travel_and_places", "char": "💒", "name": "wedding", "keywords": ["love", "like", "affection", "couple", "marriage", "bride", "groom"] }, + { "category": "travel_and_places", "char": "🏛", "name": "classical_building", "keywords": ["art", "culture", "history"] }, + { "category": "travel_and_places", "char": "⛪", "name": "church", "keywords": ["building", "religion", "christ"] }, + { "category": "travel_and_places", "char": "🕌", "name": "mosque", "keywords": ["islam", "worship", "minaret"] }, + { "category": "travel_and_places", "char": "🕍", "name": "synagogue", "keywords": ["judaism", "worship", "temple", "jewish"] }, + { "category": "travel_and_places", "char": "🕋", "name": "kaaba", "keywords": ["mecca", "mosque", "islam"] }, + { "category": "travel_and_places", "char": "⛩", "name": "shinto_shrine", "keywords": ["temple", "japan", "kyoto"] }, + { "category": "travel_and_places", "char": "🛕", "name": "hindu_temple", "keywords": ["temple"] }, + + { "category": "travel_and_places", "char": "🪨", "name": "rock", "keywords": [] }, + { "category": "travel_and_places", "char": "🪵", "name": "wood", "keywords": [] }, + { "category": "travel_and_places", "char": "🛖", "name": "hut", "keywords": [] }, + + { "category": "objects", "char": "⌚", "name": "watch", "keywords": ["time", "accessories"] }, + { "category": "objects", "char": "📱", "name": "iphone", "keywords": ["technology", "apple", "gadgets", "dial"] }, + { "category": "objects", "char": "📲", "name": "calling", "keywords": ["iphone", "incoming"] }, + { "category": "objects", "char": "💻", "name": "computer", "keywords": ["technology", "laptop", "screen", "display", "monitor"] }, + { "category": "objects", "char": "⌨", "name": "keyboard", "keywords": ["technology", "computer", "type", "input", "text"] }, + { "category": "objects", "char": "🖥", "name": "desktop_computer", "keywords": ["technology", "computing", "screen"] }, + { "category": "objects", "char": "🖨", "name": "printer", "keywords": ["paper", "ink"] }, + { "category": "objects", "char": "🖱", "name": "computer_mouse", "keywords": ["click"] }, + { "category": "objects", "char": "🖲", "name": "trackball", "keywords": ["technology", "trackpad"] }, + { "category": "objects", "char": "🕹", "name": "joystick", "keywords": ["game", "play"] }, + { "category": "objects", "char": "🗜", "name": "clamp", "keywords": ["tool"] }, + { "category": "objects", "char": "💽", "name": "minidisc", "keywords": ["technology", "record", "data", "disk", "90s"] }, + { "category": "objects", "char": "💾", "name": "floppy_disk", "keywords": ["oldschool", "technology", "save", "90s", "80s"] }, + { "category": "objects", "char": "💿", "name": "cd", "keywords": ["technology", "dvd", "disk", "disc", "90s"] }, + { "category": "objects", "char": "📀", "name": "dvd", "keywords": ["cd", "disk", "disc"] }, + { "category": "objects", "char": "📼", "name": "vhs", "keywords": ["record", "video", "oldschool", "90s", "80s"] }, + { "category": "objects", "char": "📷", "name": "camera", "keywords": ["gadgets", "photography"] }, + { "category": "objects", "char": "📸", "name": "camera_flash", "keywords": ["photography", "gadgets"] }, + { "category": "objects", "char": "📹", "name": "video_camera", "keywords": ["film", "record"] }, + { "category": "objects", "char": "🎥", "name": "movie_camera", "keywords": ["film", "record"] }, + { "category": "objects", "char": "📽", "name": "film_projector", "keywords": ["video", "tape", "record", "movie"] }, + { "category": "objects", "char": "🎞", "name": "film_strip", "keywords": ["movie"] }, + { "category": "objects", "char": "📞", "name": "telephone_receiver", "keywords": ["technology", "communication", "dial"] }, + { "category": "objects", "char": "☎️", "name": "phone", "keywords": ["technology", "communication", "dial", "telephone"] }, + { "category": "objects", "char": "📟", "name": "pager", "keywords": ["bbcall", "oldschool", "90s"] }, + { "category": "objects", "char": "📠", "name": "fax", "keywords": ["communication", "technology"] }, + { "category": "objects", "char": "📺", "name": "tv", "keywords": ["technology", "program", "oldschool", "show", "television"] }, + { "category": "objects", "char": "📻", "name": "radio", "keywords": ["communication", "music", "podcast", "program"] }, + { "category": "objects", "char": "🎙", "name": "studio_microphone", "keywords": ["sing", "recording", "artist", "talkshow"] }, + { "category": "objects", "char": "🎚", "name": "level_slider", "keywords": ["scale"] }, + { "category": "objects", "char": "🎛", "name": "control_knobs", "keywords": ["dial"] }, + { "category": "objects", "char": "🧭", "name": "compass", "keywords": ["magnetic", "navigation", "orienteering"] }, + { "category": "objects", "char": "⏱", "name": "stopwatch", "keywords": ["time", "deadline"] }, + { "category": "objects", "char": "⏲", "name": "timer_clock", "keywords": ["alarm"] }, + { "category": "objects", "char": "⏰", "name": "alarm_clock", "keywords": ["time", "wake"] }, + { "category": "objects", "char": "🕰", "name": "mantelpiece_clock", "keywords": ["time"] }, + { "category": "objects", "char": "⏳", "name": "hourglass_flowing_sand", "keywords": ["oldschool", "time", "countdown"] }, + { "category": "objects", "char": "⌛", "name": "hourglass", "keywords": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"] }, + { "category": "objects", "char": "📡", "name": "satellite", "keywords": ["communication", "future", "radio", "space"] }, + { "category": "objects", "char": "🔋", "name": "battery", "keywords": ["power", "energy", "sustain"] }, + { "category": "objects", "char": "🔌", "name": "electric_plug", "keywords": ["charger", "power"] }, + { "category": "objects", "char": "💡", "name": "bulb", "keywords": ["light", "electricity", "idea"] }, + { "category": "objects", "char": "🔦", "name": "flashlight", "keywords": ["dark", "camping", "sight", "night"] }, + { "category": "objects", "char": "🕯", "name": "candle", "keywords": ["fire", "wax"] }, + { "category": "objects", "char": "🧯", "name": "fire_extinguisher", "keywords": ["quench"] }, + { "category": "objects", "char": "🗑", "name": "wastebasket", "keywords": ["bin", "trash", "rubbish", "garbage", "toss"] }, + { "category": "objects", "char": "🛢", "name": "oil_drum", "keywords": ["barrell"] }, + { "category": "objects", "char": "💸", "name": "money_with_wings", "keywords": ["dollar", "bills", "payment", "sale"] }, + { "category": "objects", "char": "💵", "name": "dollar", "keywords": ["money", "sales", "bill", "currency"] }, + { "category": "objects", "char": "💴", "name": "yen", "keywords": ["money", "sales", "japanese", "dollar", "currency"] }, + { "category": "objects", "char": "💶", "name": "euro", "keywords": ["money", "sales", "dollar", "currency"] }, + { "category": "objects", "char": "💷", "name": "pound", "keywords": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"] }, + { "category": "objects", "char": "💰", "name": "moneybag", "keywords": ["dollar", "payment", "coins", "sale"] }, + { "category": "objects", "char": "🪙", "name": "coin", "keywords": ["dollar", "payment", "coins", "sale"] }, + { "category": "objects", "char": "💳", "name": "credit_card", "keywords": ["money", "sales", "dollar", "bill", "payment", "shopping"] }, + { "category": "objects", "char": "💎", "name": "gem", "keywords": ["blue", "ruby", "diamond", "jewelry"] }, + { "category": "objects", "char": "⚖", "name": "balance_scale", "keywords": ["law", "fairness", "weight"] }, + { "category": "objects", "char": "🧰", "name": "toolbox", "keywords": ["tools", "diy", "fix", "maintainer", "mechanic"] }, + { "category": "objects", "char": "🔧", "name": "wrench", "keywords": ["tools", "diy", "ikea", "fix", "maintainer"] }, + { "category": "objects", "char": "🔨", "name": "hammer", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "⚒", "name": "hammer_and_pick", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "🛠", "name": "hammer_and_wrench", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "⛏", "name": "pick", "keywords": ["tools", "dig"] }, + { "category": "objects", "char": "🪓", "name": "axe", "keywords": ["tools"] }, + { "category": "objects", "char": "🦯", "name": "probing_cane", "keywords": ["tools"] }, + { "category": "objects", "char": "🔩", "name": "nut_and_bolt", "keywords": ["handy", "tools", "fix"] }, + { "category": "objects", "char": "⚙", "name": "gear", "keywords": ["cog"] }, + { "category": "objects", "char": "🪃", "name": "boomerang", "keywords": ["tool"] }, + { "category": "objects", "char": "🪚", "name": "carpentry_saw", "keywords": ["tool"] }, + { "category": "objects", "char": "🪛", "name": "screwdriver", "keywords": ["tool"] }, + { "category": "objects", "char": "🪝", "name": "hook", "keywords": ["tool"] }, + { "category": "objects", "char": "🪜", "name": "ladder", "keywords": ["tool"] }, + { "category": "objects", "char": "🧱", "name": "brick", "keywords": ["bricks"] }, + { "category": "objects", "char": "⛓", "name": "chains", "keywords": ["lock", "arrest"] }, + { "category": "objects", "char": "🧲", "name": "magnet", "keywords": ["attraction", "magnetic"] }, + { "category": "objects", "char": "🔫", "name": "gun", "keywords": ["violence", "weapon", "pistol", "revolver"] }, + { "category": "objects", "char": "💣", "name": "bomb", "keywords": ["boom", "explode", "explosion", "terrorism"] }, + { "category": "objects", "char": "🧨", "name": "firecracker", "keywords": ["dynamite", "boom", "explode", "explosion", "explosive"] }, + { "category": "objects", "char": "🔪", "name": "hocho", "keywords": ["knife", "blade", "cutlery", "kitchen", "weapon"] }, + { "category": "objects", "char": "🗡", "name": "dagger", "keywords": ["weapon"] }, + { "category": "objects", "char": "⚔", "name": "crossed_swords", "keywords": ["weapon"] }, + { "category": "objects", "char": "🛡", "name": "shield", "keywords": ["protection", "security"] }, + { "category": "objects", "char": "🚬", "name": "smoking", "keywords": ["kills", "tobacco", "cigarette", "joint", "smoke"] }, + { "category": "objects", "char": "☠", "name": "skull_and_crossbones", "keywords": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"] }, + { "category": "objects", "char": "⚰", "name": "coffin", "keywords": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"] }, + { "category": "objects", "char": "⚱", "name": "funeral_urn", "keywords": ["dead", "die", "death", "rip", "ashes"] }, + { "category": "objects", "char": "🏺", "name": "amphora", "keywords": ["vase", "jar"] }, + { "category": "objects", "char": "🔮", "name": "crystal_ball", "keywords": ["disco", "party", "magic", "circus", "fortune_teller"] }, + { "category": "objects", "char": "📿", "name": "prayer_beads", "keywords": ["dhikr", "religious"] }, + { "category": "objects", "char": "🧿", "name": "nazar_amulet", "keywords": ["bead", "charm"] }, + { "category": "objects", "char": "💈", "name": "barber", "keywords": ["hair", "salon", "style"] }, + { "category": "objects", "char": "⚗", "name": "alembic", "keywords": ["distilling", "science", "experiment", "chemistry"] }, + { "category": "objects", "char": "🔭", "name": "telescope", "keywords": ["stars", "space", "zoom", "science", "astronomy"] }, + { "category": "objects", "char": "🔬", "name": "microscope", "keywords": ["laboratory", "experiment", "zoomin", "science", "study"] }, + { "category": "objects", "char": "🕳", "name": "hole", "keywords": ["embarrassing"] }, + { "category": "objects", "char": "💊", "name": "pill", "keywords": ["health", "medicine", "doctor", "pharmacy", "drug"] }, + { "category": "objects", "char": "💉", "name": "syringe", "keywords": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩸", "name": "drop_of_blood", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩹", "name": "adhesive_bandage", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩺", "name": "stethoscope", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🪒", "name": "razor", "keywords": ["health"] }, + { "category": "objects", "char": "🧬", "name": "dna", "keywords": ["biologist", "genetics", "life"] }, + { "category": "objects", "char": "🧫", "name": "petri_dish", "keywords": ["bacteria", "biology", "culture", "lab"] }, + { "category": "objects", "char": "🧪", "name": "test_tube", "keywords": ["chemistry", "experiment", "lab", "science"] }, + { "category": "objects", "char": "🌡", "name": "thermometer", "keywords": ["weather", "temperature", "hot", "cold"] }, + { "category": "objects", "char": "🧹", "name": "broom", "keywords": ["cleaning", "sweeping", "witch"] }, + { "category": "objects", "char": "🧺", "name": "basket", "keywords": ["laundry"] }, + { "category": "objects", "char": "🧻", "name": "toilet_paper", "keywords": ["roll"] }, + { "category": "objects", "char": "🏷", "name": "label", "keywords": ["sale", "tag"] }, + { "category": "objects", "char": "🔖", "name": "bookmark", "keywords": ["favorite", "label", "save"] }, + { "category": "objects", "char": "🚽", "name": "toilet", "keywords": ["restroom", "wc", "washroom", "bathroom", "potty"] }, + { "category": "objects", "char": "🚿", "name": "shower", "keywords": ["clean", "water", "bathroom"] }, + { "category": "objects", "char": "🛁", "name": "bathtub", "keywords": ["clean", "shower", "bathroom"] }, + { "category": "objects", "char": "🧼", "name": "soap", "keywords": ["bar", "bathing", "cleaning", "lather"] }, + { "category": "objects", "char": "🧽", "name": "sponge", "keywords": ["absorbing", "cleaning", "porous"] }, + { "category": "objects", "char": "🧴", "name": "lotion_bottle", "keywords": ["moisturizer", "sunscreen"] }, + { "category": "objects", "char": "🔑", "name": "key", "keywords": ["lock", "door", "password"] }, + { "category": "objects", "char": "🗝", "name": "old_key", "keywords": ["lock", "door", "password"] }, + { "category": "objects", "char": "🛋", "name": "couch_and_lamp", "keywords": ["read", "chill"] }, + { "category": "objects", "char": "🪔", "name": "diya_Lamp", "keywords": ["light", "oil"] }, + { "category": "objects", "char": "🛌", "name": "sleeping_bed", "keywords": ["bed", "rest"] }, + { "category": "objects", "char": "🛏", "name": "bed", "keywords": ["sleep", "rest"] }, + { "category": "objects", "char": "🚪", "name": "door", "keywords": ["house", "entry", "exit"] }, + { "category": "objects", "char": "🪑", "name": "chair", "keywords": ["house", "desk"] }, + { "category": "objects", "char": "🛎", "name": "bellhop_bell", "keywords": ["service"] }, + { "category": "objects", "char": "🧸", "name": "teddy_bear", "keywords": ["plush", "stuffed"] }, + { "category": "objects", "char": "🖼", "name": "framed_picture", "keywords": ["photography"] }, + { "category": "objects", "char": "🗺", "name": "world_map", "keywords": ["location", "direction"] }, + { "category": "objects", "char": "🛗", "name": "elevator", "keywords": ["household"] }, + { "category": "objects", "char": "🪞", "name": "mirror", "keywords": ["household"] }, + { "category": "objects", "char": "🪟", "name": "window", "keywords": ["household"] }, + { "category": "objects", "char": "🪠", "name": "plunger", "keywords": ["household"] }, + { "category": "objects", "char": "🪤", "name": "mouse_trap", "keywords": ["household"] }, + { "category": "objects", "char": "🪣", "name": "bucket", "keywords": ["household"] }, + { "category": "objects", "char": "🪥", "name": "toothbrush", "keywords": ["household"] }, + { "category": "objects", "char": "⛱", "name": "parasol_on_ground", "keywords": ["weather", "summer"] }, + { "category": "objects", "char": "🗿", "name": "moyai", "keywords": ["rock", "easter island", "moai"] }, + { "category": "objects", "char": "🛍", "name": "shopping", "keywords": ["mall", "buy", "purchase"] }, + { "category": "objects", "char": "🛒", "name": "shopping_cart", "keywords": ["trolley"] }, + { "category": "objects", "char": "🎈", "name": "balloon", "keywords": ["party", "celebration", "birthday", "circus"] }, + { "category": "objects", "char": "🎏", "name": "flags", "keywords": ["fish", "japanese", "koinobori", "carp", "banner"] }, + { "category": "objects", "char": "🎀", "name": "ribbon", "keywords": ["decoration", "pink", "girl", "bowtie"] }, + { "category": "objects", "char": "🎁", "name": "gift", "keywords": ["present", "birthday", "christmas", "xmas"] }, + { "category": "objects", "char": "🎊", "name": "confetti_ball", "keywords": ["festival", "party", "birthday", "circus"] }, + { "category": "objects", "char": "🎉", "name": "tada", "keywords": ["party", "congratulations", "birthday", "magic", "circus", "celebration"] }, + { "category": "objects", "char": "🎎", "name": "dolls", "keywords": ["japanese", "toy", "kimono"] }, + { "category": "objects", "char": "🎐", "name": "wind_chime", "keywords": ["nature", "ding", "spring", "bell"] }, + { "category": "objects", "char": "🎌", "name": "crossed_flags", "keywords": ["japanese", "nation", "country", "border"] }, + { "category": "objects", "char": "🏮", "name": "izakaya_lantern", "keywords": ["light", "paper", "halloween", "spooky"] }, + { "category": "objects", "char": "🧧", "name": "red_envelope", "keywords": ["gift"] }, + { "category": "objects", "char": "✉️", "name": "email", "keywords": ["letter", "postal", "inbox", "communication"] }, + { "category": "objects", "char": "📩", "name": "envelope_with_arrow", "keywords": ["email", "communication"] }, + { "category": "objects", "char": "📨", "name": "incoming_envelope", "keywords": ["email", "inbox"] }, + { "category": "objects", "char": "📧", "name": "e-mail", "keywords": ["communication", "inbox"] }, + { "category": "objects", "char": "💌", "name": "love_letter", "keywords": ["email", "like", "affection", "envelope", "valentines"] }, + { "category": "objects", "char": "📮", "name": "postbox", "keywords": ["email", "letter", "envelope"] }, + { "category": "objects", "char": "📪", "name": "mailbox_closed", "keywords": ["email", "communication", "inbox"] }, + { "category": "objects", "char": "📫", "name": "mailbox", "keywords": ["email", "inbox", "communication"] }, + { "category": "objects", "char": "📬", "name": "mailbox_with_mail", "keywords": ["email", "inbox", "communication"] }, + { "category": "objects", "char": "📭", "name": "mailbox_with_no_mail", "keywords": ["email", "inbox"] }, + { "category": "objects", "char": "📦", "name": "package", "keywords": ["mail", "gift", "cardboard", "box", "moving"] }, + { "category": "objects", "char": "📯", "name": "postal_horn", "keywords": ["instrument", "music"] }, + { "category": "objects", "char": "📥", "name": "inbox_tray", "keywords": ["email", "documents"] }, + { "category": "objects", "char": "📤", "name": "outbox_tray", "keywords": ["inbox", "email"] }, + { "category": "objects", "char": "📜", "name": "scroll", "keywords": ["documents", "ancient", "history", "paper"] }, + { "category": "objects", "char": "📃", "name": "page_with_curl", "keywords": ["documents", "office", "paper"] }, + { "category": "objects", "char": "📑", "name": "bookmark_tabs", "keywords": ["favorite", "save", "order", "tidy"] }, + { "category": "objects", "char": "🧾", "name": "receipt", "keywords": ["accounting", "expenses"] }, + { "category": "objects", "char": "📊", "name": "bar_chart", "keywords": ["graph", "presentation", "stats"] }, + { "category": "objects", "char": "📈", "name": "chart_with_upwards_trend", "keywords": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"] }, + { "category": "objects", "char": "📉", "name": "chart_with_downwards_trend", "keywords": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"] }, + { "category": "objects", "char": "📄", "name": "page_facing_up", "keywords": ["documents", "office", "paper", "information"] }, + { "category": "objects", "char": "📅", "name": "date", "keywords": ["calendar", "schedule"] }, + { "category": "objects", "char": "📆", "name": "calendar", "keywords": ["schedule", "date", "planning"] }, + { "category": "objects", "char": "🗓", "name": "spiral_calendar", "keywords": ["date", "schedule", "planning"] }, + { "category": "objects", "char": "📇", "name": "card_index", "keywords": ["business", "stationery"] }, + { "category": "objects", "char": "🗃", "name": "card_file_box", "keywords": ["business", "stationery"] }, + { "category": "objects", "char": "🗳", "name": "ballot_box", "keywords": ["election", "vote"] }, + { "category": "objects", "char": "🗄", "name": "file_cabinet", "keywords": ["filing", "organizing"] }, + { "category": "objects", "char": "📋", "name": "clipboard", "keywords": ["stationery", "documents"] }, + { "category": "objects", "char": "🗒", "name": "spiral_notepad", "keywords": ["memo", "stationery"] }, + { "category": "objects", "char": "📁", "name": "file_folder", "keywords": ["documents", "business", "office"] }, + { "category": "objects", "char": "📂", "name": "open_file_folder", "keywords": ["documents", "load"] }, + { "category": "objects", "char": "🗂", "name": "card_index_dividers", "keywords": ["organizing", "business", "stationery"] }, + { "category": "objects", "char": "🗞", "name": "newspaper_roll", "keywords": ["press", "headline"] }, + { "category": "objects", "char": "📰", "name": "newspaper", "keywords": ["press", "headline"] }, + { "category": "objects", "char": "📓", "name": "notebook", "keywords": ["stationery", "record", "notes", "paper", "study"] }, + { "category": "objects", "char": "📕", "name": "closed_book", "keywords": ["read", "library", "knowledge", "textbook", "learn"] }, + { "category": "objects", "char": "📗", "name": "green_book", "keywords": ["read", "library", "knowledge", "study"] }, + { "category": "objects", "char": "📘", "name": "blue_book", "keywords": ["read", "library", "knowledge", "learn", "study"] }, + { "category": "objects", "char": "📙", "name": "orange_book", "keywords": ["read", "library", "knowledge", "textbook", "study"] }, + { "category": "objects", "char": "📔", "name": "notebook_with_decorative_cover", "keywords": ["classroom", "notes", "record", "paper", "study"] }, + { "category": "objects", "char": "📒", "name": "ledger", "keywords": ["notes", "paper"] }, + { "category": "objects", "char": "📚", "name": "books", "keywords": ["literature", "library", "study"] }, + { "category": "objects", "char": "📖", "name": "open_book", "keywords": ["book", "read", "library", "knowledge", "literature", "learn", "study"] }, + { "category": "objects", "char": "🧷", "name": "safety_pin", "keywords": ["diaper"] }, + { "category": "objects", "char": "🔗", "name": "link", "keywords": ["rings", "url"] }, + { "category": "objects", "char": "📎", "name": "paperclip", "keywords": ["documents", "stationery"] }, + { "category": "objects", "char": "🖇", "name": "paperclips", "keywords": ["documents", "stationery"] }, + { "category": "objects", "char": "✂️", "name": "scissors", "keywords": ["stationery", "cut"] }, + { "category": "objects", "char": "📐", "name": "triangular_ruler", "keywords": ["stationery", "math", "architect", "sketch"] }, + { "category": "objects", "char": "📏", "name": "straight_ruler", "keywords": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"] }, + { "category": "objects", "char": "🧮", "name": "abacus", "keywords": ["calculation"] }, + { "category": "objects", "char": "📌", "name": "pushpin", "keywords": ["stationery", "mark", "here"] }, + { "category": "objects", "char": "📍", "name": "round_pushpin", "keywords": ["stationery", "location", "map", "here"] }, + { "category": "objects", "char": "🚩", "name": "triangular_flag_on_post", "keywords": ["mark", "milestone", "place"] }, + { "category": "objects", "char": "🏳", "name": "white_flag", "keywords": ["losing", "loser", "lost", "surrender", "give up", "fail"] }, + { "category": "objects", "char": "🏴", "name": "black_flag", "keywords": ["pirate"] }, + { "category": "objects", "char": "🏳️🌈", "name": "rainbow_flag", "keywords": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"] }, + { "category": "objects", "char": "🏳️⚧️", "name": "transgender_flag", "keywords": ["flag", "transgender"] }, + { "category": "objects", "char": "🔐", "name": "closed_lock_with_key", "keywords": ["security", "privacy"] }, + { "category": "objects", "char": "🔒", "name": "lock", "keywords": ["security", "password", "padlock"] }, + { "category": "objects", "char": "🔓", "name": "unlock", "keywords": ["privacy", "security"] }, + { "category": "objects", "char": "🔏", "name": "lock_with_ink_pen", "keywords": ["security", "secret"] }, + { "category": "objects", "char": "🖊", "name": "pen", "keywords": ["stationery", "writing", "write"] }, + { "category": "objects", "char": "🖋", "name": "fountain_pen", "keywords": ["stationery", "writing", "write"] }, + { "category": "objects", "char": "✒️", "name": "black_nib", "keywords": ["pen", "stationery", "writing", "write"] }, + { "category": "objects", "char": "📝", "name": "memo", "keywords": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"] }, + { "category": "objects", "char": "✏️", "name": "pencil2", "keywords": ["stationery", "write", "paper", "writing", "school", "study"] }, + { "category": "objects", "char": "🖍", "name": "crayon", "keywords": ["drawing", "creativity"] }, + { "category": "objects", "char": "🖌", "name": "paintbrush", "keywords": ["drawing", "creativity", "art"] }, + { "category": "objects", "char": "🔍", "name": "mag", "keywords": ["search", "zoom", "find", "detective"] }, + { "category": "objects", "char": "🔎", "name": "mag_right", "keywords": ["search", "zoom", "find", "detective"] }, + { "category": "objects", "char": "🪦", "name": "headstone", "keywords": [] }, + { "category": "objects", "char": "🪧", "name": "placard", "keywords": [] }, + { "category": "symbols", "char": "💯", "name": "100", "keywords": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"] }, + { "category": "symbols", "char": "🔢", "name": "1234", "keywords": ["numbers", "blue-square"] }, + { "category": "symbols", "char": "❤️", "name": "heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🧡", "name": "orange_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💛", "name": "yellow_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💚", "name": "green_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💙", "name": "blue_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💜", "name": "purple_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🤎", "name": "brown_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🖤", "name": "black_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🤍", "name": "white_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💔", "name": "broken_heart", "keywords": ["sad", "sorry", "break", "heart", "heartbreak"] }, + { "category": "symbols", "char": "❣", "name": "heavy_heart_exclamation", "keywords": ["decoration", "love"] }, + { "category": "symbols", "char": "💕", "name": "two_hearts", "keywords": ["love", "like", "affection", "valentines", "heart"] }, + { "category": "symbols", "char": "💞", "name": "revolving_hearts", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💓", "name": "heartbeat", "keywords": ["love", "like", "affection", "valentines", "pink", "heart"] }, + { "category": "symbols", "char": "💗", "name": "heartpulse", "keywords": ["like", "love", "affection", "valentines", "pink"] }, + { "category": "symbols", "char": "💖", "name": "sparkling_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💘", "name": "cupid", "keywords": ["love", "like", "heart", "affection", "valentines"] }, + { "category": "symbols", "char": "💝", "name": "gift_heart", "keywords": ["love", "valentines"] }, + { "category": "symbols", "char": "💟", "name": "heart_decoration", "keywords": ["purple-square", "love", "like"] }, + { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83D\uDD25", "name": "heart_on_fire", "keywords": [] }, + { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83E\uDE79", "name": "mending_heart", "keywords": [] }, + { "category": "symbols", "char": "☮", "name": "peace_symbol", "keywords": ["hippie"] }, + { "category": "symbols", "char": "✝", "name": "latin_cross", "keywords": ["christianity"] }, + { "category": "symbols", "char": "☪", "name": "star_and_crescent", "keywords": ["islam"] }, + { "category": "symbols", "char": "🕉", "name": "om", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, + { "category": "symbols", "char": "☸", "name": "wheel_of_dharma", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, + { "category": "symbols", "char": "✡", "name": "star_of_david", "keywords": ["judaism"] }, + { "category": "symbols", "char": "🔯", "name": "six_pointed_star", "keywords": ["purple-square", "religion", "jewish", "hexagram"] }, + { "category": "symbols", "char": "🕎", "name": "menorah", "keywords": ["hanukkah", "candles", "jewish"] }, + { "category": "symbols", "char": "☯", "name": "yin_yang", "keywords": ["balance"] }, + { "category": "symbols", "char": "☦", "name": "orthodox_cross", "keywords": ["suppedaneum", "religion"] }, + { "category": "symbols", "char": "🛐", "name": "place_of_worship", "keywords": ["religion", "church", "temple", "prayer"] }, + { "category": "symbols", "char": "⛎", "name": "ophiuchus", "keywords": ["sign", "purple-square", "constellation", "astrology"] }, + { "category": "symbols", "char": "♈", "name": "aries", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♉", "name": "taurus", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♊", "name": "gemini", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♋", "name": "cancer", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♌", "name": "leo", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♍", "name": "virgo", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♎", "name": "libra", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♏", "name": "scorpius", "keywords": ["sign", "zodiac", "purple-square", "astrology", "scorpio"] }, + { "category": "symbols", "char": "♐", "name": "sagittarius", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♑", "name": "capricorn", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♒", "name": "aquarius", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♓", "name": "pisces", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, + { "category": "symbols", "char": "🆔", "name": "id", "keywords": ["purple-square", "words"] }, + { "category": "symbols", "char": "⚛", "name": "atom_symbol", "keywords": ["science", "physics", "chemistry"] }, + { "category": "symbols", "char": "⚧️", "name": "transgender_symbol", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, + { "category": "symbols", "char": "🈳", "name": "u7a7a", "keywords": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"] }, + { "category": "symbols", "char": "🈹", "name": "u5272", "keywords": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"] }, + { "category": "symbols", "char": "☢", "name": "radioactive", "keywords": ["nuclear", "danger"] }, + { "category": "symbols", "char": "☣", "name": "biohazard", "keywords": ["danger"] }, + { "category": "symbols", "char": "📴", "name": "mobile_phone_off", "keywords": ["mute", "orange-square", "silence", "quiet"] }, + { "category": "symbols", "char": "📳", "name": "vibration_mode", "keywords": ["orange-square", "phone"] }, + { "category": "symbols", "char": "🈶", "name": "u6709", "keywords": ["orange-square", "chinese", "have", "kanji", "ari"] }, + { "category": "symbols", "char": "🈚", "name": "u7121", "keywords": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"] }, + { "category": "symbols", "char": "🈸", "name": "u7533", "keywords": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"] }, + { "category": "symbols", "char": "🈺", "name": "u55b6", "keywords": ["japanese", "opening hours", "orange-square", "eigyo"] }, + { "category": "symbols", "char": "🈷️", "name": "u6708", "keywords": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"] }, + { "category": "symbols", "char": "✴️", "name": "eight_pointed_black_star", "keywords": ["orange-square", "shape", "polygon"] }, + { "category": "symbols", "char": "🆚", "name": "vs", "keywords": ["words", "orange-square"] }, + { "category": "symbols", "char": "🉑", "name": "accept", "keywords": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"] }, + { "category": "symbols", "char": "💮", "name": "white_flower", "keywords": ["japanese", "spring"] }, + { "category": "symbols", "char": "🉐", "name": "ideograph_advantage", "keywords": ["chinese", "kanji", "obtain", "get", "circle"] }, + { "category": "symbols", "char": "㊙️", "name": "secret", "keywords": ["privacy", "chinese", "sshh", "kanji", "red-circle"] }, + { "category": "symbols", "char": "㊗️", "name": "congratulations", "keywords": ["chinese", "kanji", "japanese", "red-circle"] }, + { "category": "symbols", "char": "🈴", "name": "u5408", "keywords": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"] }, + { "category": "symbols", "char": "🈵", "name": "u6e80", "keywords": ["full", "chinese", "japanese", "red-square", "kanji", "man"] }, + { "category": "symbols", "char": "🈲", "name": "u7981", "keywords": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"] }, + { "category": "symbols", "char": "🅰️", "name": "a", "keywords": ["red-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🅱️", "name": "b", "keywords": ["red-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🆎", "name": "ab", "keywords": ["red-square", "alphabet"] }, + { "category": "symbols", "char": "🆑", "name": "cl", "keywords": ["alphabet", "words", "red-square"] }, + { "category": "symbols", "char": "🅾️", "name": "o2", "keywords": ["alphabet", "red-square", "letter"] }, + { "category": "symbols", "char": "🆘", "name": "sos", "keywords": ["help", "red-square", "words", "emergency", "911"] }, + { "category": "symbols", "char": "⛔", "name": "no_entry", "keywords": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"] }, + { "category": "symbols", "char": "📛", "name": "name_badge", "keywords": ["fire", "forbid"] }, + { "category": "symbols", "char": "🚫", "name": "no_entry_sign", "keywords": ["forbid", "stop", "limit", "denied", "disallow", "circle"] }, + { "category": "symbols", "char": "❌", "name": "x", "keywords": ["no", "delete", "remove", "cancel", "red"] }, + { "category": "symbols", "char": "⭕", "name": "o", "keywords": ["circle", "round"] }, + { "category": "symbols", "char": "🛑", "name": "stop_sign", "keywords": ["stop"] }, + { "category": "symbols", "char": "💢", "name": "anger", "keywords": ["angry", "mad"] }, + { "category": "symbols", "char": "♨️", "name": "hotsprings", "keywords": ["bath", "warm", "relax"] }, + { "category": "symbols", "char": "🚷", "name": "no_pedestrians", "keywords": ["rules", "crossing", "walking", "circle"] }, + { "category": "symbols", "char": "🚯", "name": "do_not_litter", "keywords": ["trash", "bin", "garbage", "circle"] }, + { "category": "symbols", "char": "🚳", "name": "no_bicycles", "keywords": ["cyclist", "prohibited", "circle"] }, + { "category": "symbols", "char": "🚱", "name": "non-potable_water", "keywords": ["drink", "faucet", "tap", "circle"] }, + { "category": "symbols", "char": "🔞", "name": "underage", "keywords": ["18", "drink", "pub", "night", "minor", "circle"] }, + { "category": "symbols", "char": "📵", "name": "no_mobile_phones", "keywords": ["iphone", "mute", "circle"] }, + { "category": "symbols", "char": "❗", "name": "exclamation", "keywords": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"] }, + { "category": "symbols", "char": "❕", "name": "grey_exclamation", "keywords": ["surprise", "punctuation", "gray", "wow", "warning"] }, + { "category": "symbols", "char": "❓", "name": "question", "keywords": ["doubt", "confused"] }, + { "category": "symbols", "char": "❔", "name": "grey_question", "keywords": ["doubts", "gray", "huh", "confused"] }, + { "category": "symbols", "char": "‼️", "name": "bangbang", "keywords": ["exclamation", "surprise"] }, + { "category": "symbols", "char": "⁉️", "name": "interrobang", "keywords": ["wat", "punctuation", "surprise"] }, + { "category": "symbols", "char": "🔅", "name": "low_brightness", "keywords": ["sun", "afternoon", "warm", "summer"] }, + { "category": "symbols", "char": "🔆", "name": "high_brightness", "keywords": ["sun", "light"] }, + { "category": "symbols", "char": "🔱", "name": "trident", "keywords": ["weapon", "spear"] }, + { "category": "symbols", "char": "⚜", "name": "fleur_de_lis", "keywords": ["decorative", "scout"] }, + { "category": "symbols", "char": "〽️", "name": "part_alternation_mark", "keywords": ["graph", "presentation", "stats", "business", "economics", "bad"] }, + { "category": "symbols", "char": "⚠️", "name": "warning", "keywords": ["exclamation", "wip", "alert", "error", "problem", "issue"] }, + { "category": "symbols", "char": "🚸", "name": "children_crossing", "keywords": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"] }, + { "category": "symbols", "char": "🔰", "name": "beginner", "keywords": ["badge", "shield"] }, + { "category": "symbols", "char": "♻️", "name": "recycle", "keywords": ["arrow", "environment", "garbage", "trash"] }, + { "category": "symbols", "char": "🈯", "name": "u6307", "keywords": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"] }, + { "category": "symbols", "char": "💹", "name": "chart", "keywords": ["green-square", "graph", "presentation", "stats"] }, + { "category": "symbols", "char": "❇️", "name": "sparkle", "keywords": ["stars", "green-square", "awesome", "good", "fireworks"] }, + { "category": "symbols", "char": "✳️", "name": "eight_spoked_asterisk", "keywords": ["star", "sparkle", "green-square"] }, + { "category": "symbols", "char": "❎", "name": "negative_squared_cross_mark", "keywords": ["x", "green-square", "no", "deny"] }, + { "category": "symbols", "char": "✅", "name": "white_check_mark", "keywords": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"] }, + { "category": "symbols", "char": "💠", "name": "diamond_shape_with_a_dot_inside", "keywords": ["jewel", "blue", "gem", "crystal", "fancy"] }, + { "category": "symbols", "char": "🌀", "name": "cyclone", "keywords": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"] }, + { "category": "symbols", "char": "➿", "name": "loop", "keywords": ["tape", "cassette"] }, + { "category": "symbols", "char": "🌐", "name": "globe_with_meridians", "keywords": ["earth", "international", "world", "internet", "interweb", "i18n"] }, + { "category": "symbols", "char": "Ⓜ️", "name": "m", "keywords": ["alphabet", "blue-circle", "letter"] }, + { "category": "symbols", "char": "🏧", "name": "atm", "keywords": ["money", "sales", "cash", "blue-square", "payment", "bank"] }, + { "category": "symbols", "char": "🈂️", "name": "sa", "keywords": ["japanese", "blue-square", "katakana"] }, + { "category": "symbols", "char": "🛂", "name": "passport_control", "keywords": ["custom", "blue-square"] }, + { "category": "symbols", "char": "🛃", "name": "customs", "keywords": ["passport", "border", "blue-square"] }, + { "category": "symbols", "char": "🛄", "name": "baggage_claim", "keywords": ["blue-square", "airport", "transport"] }, + { "category": "symbols", "char": "🛅", "name": "left_luggage", "keywords": ["blue-square", "travel"] }, + { "category": "symbols", "char": "♿", "name": "wheelchair", "keywords": ["blue-square", "disabled", "a11y", "accessibility"] }, + { "category": "symbols", "char": "🚭", "name": "no_smoking", "keywords": ["cigarette", "blue-square", "smell", "smoke"] }, + { "category": "symbols", "char": "🚾", "name": "wc", "keywords": ["toilet", "restroom", "blue-square"] }, + { "category": "symbols", "char": "🅿️", "name": "parking", "keywords": ["cars", "blue-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🚰", "name": "potable_water", "keywords": ["blue-square", "liquid", "restroom", "cleaning", "faucet"] }, + { "category": "symbols", "char": "🚹", "name": "mens", "keywords": ["toilet", "restroom", "wc", "blue-square", "gender", "male"] }, + { "category": "symbols", "char": "🚺", "name": "womens", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, + { "category": "symbols", "char": "🚼", "name": "baby_symbol", "keywords": ["orange-square", "child"] }, + { "category": "symbols", "char": "🚻", "name": "restroom", "keywords": ["blue-square", "toilet", "refresh", "wc", "gender"] }, + { "category": "symbols", "char": "🚮", "name": "put_litter_in_its_place", "keywords": ["blue-square", "sign", "human", "info"] }, + { "category": "symbols", "char": "🎦", "name": "cinema", "keywords": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"] }, + { "category": "symbols", "char": "📶", "name": "signal_strength", "keywords": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"] }, + { "category": "symbols", "char": "🈁", "name": "koko", "keywords": ["blue-square", "here", "katakana", "japanese", "destination"] }, + { "category": "symbols", "char": "🆖", "name": "ng", "keywords": ["blue-square", "words", "shape", "icon"] }, + { "category": "symbols", "char": "🆗", "name": "ok", "keywords": ["good", "agree", "yes", "blue-square"] }, + { "category": "symbols", "char": "🆙", "name": "up", "keywords": ["blue-square", "above", "high"] }, + { "category": "symbols", "char": "🆒", "name": "cool", "keywords": ["words", "blue-square"] }, + { "category": "symbols", "char": "🆕", "name": "new", "keywords": ["blue-square", "words", "start"] }, + { "category": "symbols", "char": "🆓", "name": "free", "keywords": ["blue-square", "words"] }, + { "category": "symbols", "char": "0️⃣", "name": "zero", "keywords": ["0", "numbers", "blue-square", "null"] }, + { "category": "symbols", "char": "1️⃣", "name": "one", "keywords": ["blue-square", "numbers", "1"] }, + { "category": "symbols", "char": "2️⃣", "name": "two", "keywords": ["numbers", "2", "prime", "blue-square"] }, + { "category": "symbols", "char": "3️⃣", "name": "three", "keywords": ["3", "numbers", "prime", "blue-square"] }, + { "category": "symbols", "char": "4️⃣", "name": "four", "keywords": ["4", "numbers", "blue-square"] }, + { "category": "symbols", "char": "5️⃣", "name": "five", "keywords": ["5", "numbers", "blue-square", "prime"] }, + { "category": "symbols", "char": "6️⃣", "name": "six", "keywords": ["6", "numbers", "blue-square"] }, + { "category": "symbols", "char": "7️⃣", "name": "seven", "keywords": ["7", "numbers", "blue-square", "prime"] }, + { "category": "symbols", "char": "8️⃣", "name": "eight", "keywords": ["8", "blue-square", "numbers"] }, + { "category": "symbols", "char": "9️⃣", "name": "nine", "keywords": ["blue-square", "numbers", "9"] }, + { "category": "symbols", "char": "🔟", "name": "keycap_ten", "keywords": ["numbers", "10", "blue-square"] }, + { "category": "symbols", "char": "*⃣", "name": "asterisk", "keywords": ["star", "keycap"] }, + { "category": "symbols", "char": "⏏️", "name": "eject_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "▶️", "name": "arrow_forward", "keywords": ["blue-square", "right", "direction", "play"] }, + { "category": "symbols", "char": "⏸", "name": "pause_button", "keywords": ["pause", "blue-square"] }, + { "category": "symbols", "char": "⏭", "name": "next_track_button", "keywords": ["forward", "next", "blue-square"] }, + { "category": "symbols", "char": "⏹", "name": "stop_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "⏺", "name": "record_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "⏯", "name": "play_or_pause_button", "keywords": ["blue-square", "play", "pause"] }, + { "category": "symbols", "char": "⏮", "name": "previous_track_button", "keywords": ["backward"] }, + { "category": "symbols", "char": "⏩", "name": "fast_forward", "keywords": ["blue-square", "play", "speed", "continue"] }, + { "category": "symbols", "char": "⏪", "name": "rewind", "keywords": ["play", "blue-square"] }, + { "category": "symbols", "char": "🔀", "name": "twisted_rightwards_arrows", "keywords": ["blue-square", "shuffle", "music", "random"] }, + { "category": "symbols", "char": "🔁", "name": "repeat", "keywords": ["loop", "record"] }, + { "category": "symbols", "char": "🔂", "name": "repeat_one", "keywords": ["blue-square", "loop"] }, + { "category": "symbols", "char": "◀️", "name": "arrow_backward", "keywords": ["blue-square", "left", "direction"] }, + { "category": "symbols", "char": "🔼", "name": "arrow_up_small", "keywords": ["blue-square", "triangle", "direction", "point", "forward", "top"] }, + { "category": "symbols", "char": "🔽", "name": "arrow_down_small", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "⏫", "name": "arrow_double_up", "keywords": ["blue-square", "direction", "top"] }, + { "category": "symbols", "char": "⏬", "name": "arrow_double_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "➡️", "name": "arrow_right", "keywords": ["blue-square", "next"] }, + { "category": "symbols", "char": "⬅️", "name": "arrow_left", "keywords": ["blue-square", "previous", "back"] }, + { "category": "symbols", "char": "⬆️", "name": "arrow_up", "keywords": ["blue-square", "continue", "top", "direction"] }, + { "category": "symbols", "char": "⬇️", "name": "arrow_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "↗️", "name": "arrow_upper_right", "keywords": ["blue-square", "point", "direction", "diagonal", "northeast"] }, + { "category": "symbols", "char": "↘️", "name": "arrow_lower_right", "keywords": ["blue-square", "direction", "diagonal", "southeast"] }, + { "category": "symbols", "char": "↙️", "name": "arrow_lower_left", "keywords": ["blue-square", "direction", "diagonal", "southwest"] }, + { "category": "symbols", "char": "↖️", "name": "arrow_upper_left", "keywords": ["blue-square", "point", "direction", "diagonal", "northwest"] }, + { "category": "symbols", "char": "↕️", "name": "arrow_up_down", "keywords": ["blue-square", "direction", "way", "vertical"] }, + { "category": "symbols", "char": "↔️", "name": "left_right_arrow", "keywords": ["shape", "direction", "horizontal", "sideways"] }, + { "category": "symbols", "char": "🔄", "name": "arrows_counterclockwise", "keywords": ["blue-square", "sync", "cycle"] }, + { "category": "symbols", "char": "↪️", "name": "arrow_right_hook", "keywords": ["blue-square", "return", "rotate", "direction"] }, + { "category": "symbols", "char": "↩️", "name": "leftwards_arrow_with_hook", "keywords": ["back", "return", "blue-square", "undo", "enter"] }, + { "category": "symbols", "char": "⤴️", "name": "arrow_heading_up", "keywords": ["blue-square", "direction", "top"] }, + { "category": "symbols", "char": "⤵️", "name": "arrow_heading_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "#️⃣", "name": "hash", "keywords": ["symbol", "blue-square", "twitter"] }, + { "category": "symbols", "char": "ℹ️", "name": "information_source", "keywords": ["blue-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🔤", "name": "abc", "keywords": ["blue-square", "alphabet"] }, + { "category": "symbols", "char": "🔡", "name": "abcd", "keywords": ["blue-square", "alphabet"] }, + { "category": "symbols", "char": "🔠", "name": "capital_abcd", "keywords": ["alphabet", "words", "blue-square"] }, + { "category": "symbols", "char": "🔣", "name": "symbols", "keywords": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"] }, + { "category": "symbols", "char": "🎵", "name": "musical_note", "keywords": ["score", "tone", "sound"] }, + { "category": "symbols", "char": "🎶", "name": "notes", "keywords": ["music", "score"] }, + { "category": "symbols", "char": "〰️", "name": "wavy_dash", "keywords": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"] }, + { "category": "symbols", "char": "➰", "name": "curly_loop", "keywords": ["scribble", "draw", "shape", "squiggle"] }, + { "category": "symbols", "char": "✔️", "name": "heavy_check_mark", "keywords": ["ok", "nike", "answer", "yes", "tick"] }, + { "category": "symbols", "char": "🔃", "name": "arrows_clockwise", "keywords": ["sync", "cycle", "round", "repeat"] }, + { "category": "symbols", "char": "➕", "name": "heavy_plus_sign", "keywords": ["math", "calculation", "addition", "more", "increase"] }, + { "category": "symbols", "char": "➖", "name": "heavy_minus_sign", "keywords": ["math", "calculation", "subtract", "less"] }, + { "category": "symbols", "char": "➗", "name": "heavy_division_sign", "keywords": ["divide", "math", "calculation"] }, + { "category": "symbols", "char": "✖️", "name": "heavy_multiplication_x", "keywords": ["math", "calculation"] }, + { "category": "symbols", "char": "♾", "name": "infinity", "keywords": ["forever"] }, + { "category": "symbols", "char": "💲", "name": "heavy_dollar_sign", "keywords": ["money", "sales", "payment", "currency", "buck"] }, + { "category": "symbols", "char": "💱", "name": "currency_exchange", "keywords": ["money", "sales", "dollar", "travel"] }, + { "category": "symbols", "char": "©️", "name": "copyright", "keywords": ["ip", "license", "circle", "law", "legal"] }, + { "category": "symbols", "char": "®️", "name": "registered", "keywords": ["alphabet", "circle"] }, + { "category": "symbols", "char": "™️", "name": "tm", "keywords": ["trademark", "brand", "law", "legal"] }, + { "category": "symbols", "char": "🔚", "name": "end", "keywords": ["words", "arrow"] }, + { "category": "symbols", "char": "🔙", "name": "back", "keywords": ["arrow", "words", "return"] }, + { "category": "symbols", "char": "🔛", "name": "on", "keywords": ["arrow", "words"] }, + { "category": "symbols", "char": "🔝", "name": "top", "keywords": ["words", "blue-square"] }, + { "category": "symbols", "char": "🔜", "name": "soon", "keywords": ["arrow", "words"] }, + { "category": "symbols", "char": "☑️", "name": "ballot_box_with_check", "keywords": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"] }, + { "category": "symbols", "char": "🔘", "name": "radio_button", "keywords": ["input", "old", "music", "circle"] }, + { "category": "symbols", "char": "⚫", "name": "black_circle", "keywords": ["shape", "button", "round"] }, + { "category": "symbols", "char": "⚪", "name": "white_circle", "keywords": ["shape", "round"] }, + { "category": "symbols", "char": "🔴", "name": "red_circle", "keywords": ["shape", "error", "danger"] }, + { "category": "symbols", "char": "🟠", "name": "orange_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟡", "name": "yellow_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟢", "name": "green_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔵", "name": "large_blue_circle", "keywords": ["shape", "icon", "button"] }, + { "category": "symbols", "char": "🟣", "name": "purple_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟤", "name": "brown_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔸", "name": "small_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔹", "name": "small_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔶", "name": "large_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔷", "name": "large_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔺", "name": "small_red_triangle", "keywords": ["shape", "direction", "up", "top"] }, + { "category": "symbols", "char": "▪️", "name": "black_small_square", "keywords": ["shape", "icon"] }, + { "category": "symbols", "char": "▫️", "name": "white_small_square", "keywords": ["shape", "icon"] }, + { "category": "symbols", "char": "⬛", "name": "black_large_square", "keywords": ["shape", "icon", "button"] }, + { "category": "symbols", "char": "⬜", "name": "white_large_square", "keywords": ["shape", "icon", "stone", "button"] }, + { "category": "symbols", "char": "🟥", "name": "red_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟧", "name": "orange_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟨", "name": "yellow_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟩", "name": "green_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟦", "name": "blue_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟪", "name": "purple_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟫", "name": "brown_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔻", "name": "small_red_triangle_down", "keywords": ["shape", "direction", "bottom"] }, + { "category": "symbols", "char": "◼️", "name": "black_medium_square", "keywords": ["shape", "button", "icon"] }, + { "category": "symbols", "char": "◻️", "name": "white_medium_square", "keywords": ["shape", "stone", "icon"] }, + { "category": "symbols", "char": "◾", "name": "black_medium_small_square", "keywords": ["icon", "shape", "button"] }, + { "category": "symbols", "char": "◽", "name": "white_medium_small_square", "keywords": ["shape", "stone", "icon", "button"] }, + { "category": "symbols", "char": "🔲", "name": "black_square_button", "keywords": ["shape", "input", "frame"] }, + { "category": "symbols", "char": "🔳", "name": "white_square_button", "keywords": ["shape", "input"] }, + { "category": "symbols", "char": "🔈", "name": "speaker", "keywords": ["sound", "volume", "silence", "broadcast"] }, + { "category": "symbols", "char": "🔉", "name": "sound", "keywords": ["volume", "speaker", "broadcast"] }, + { "category": "symbols", "char": "🔊", "name": "loud_sound", "keywords": ["volume", "noise", "noisy", "speaker", "broadcast"] }, + { "category": "symbols", "char": "🔇", "name": "mute", "keywords": ["sound", "volume", "silence", "quiet"] }, + { "category": "symbols", "char": "📣", "name": "mega", "keywords": ["sound", "speaker", "volume"] }, + { "category": "symbols", "char": "📢", "name": "loudspeaker", "keywords": ["volume", "sound"] }, + { "category": "symbols", "char": "🔔", "name": "bell", "keywords": ["sound", "notification", "christmas", "xmas", "chime"] }, + { "category": "symbols", "char": "🔕", "name": "no_bell", "keywords": ["sound", "volume", "mute", "quiet", "silent"] }, + { "category": "symbols", "char": "🃏", "name": "black_joker", "keywords": ["poker", "cards", "game", "play", "magic"] }, + { "category": "symbols", "char": "🀄", "name": "mahjong", "keywords": ["game", "play", "chinese", "kanji"] }, + { "category": "symbols", "char": "♠️", "name": "spades", "keywords": ["poker", "cards", "suits", "magic"] }, + { "category": "symbols", "char": "♣️", "name": "clubs", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "♥️", "name": "hearts", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "♦️", "name": "diamonds", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "🎴", "name": "flower_playing_cards", "keywords": ["game", "sunset", "red"] }, + { "category": "symbols", "char": "💭", "name": "thought_balloon", "keywords": ["bubble", "cloud", "speech", "thinking", "dream"] }, + { "category": "symbols", "char": "🗯", "name": "right_anger_bubble", "keywords": ["caption", "speech", "thinking", "mad"] }, + { "category": "symbols", "char": "💬", "name": "speech_balloon", "keywords": ["bubble", "words", "message", "talk", "chatting"] }, + { "category": "symbols", "char": "🗨", "name": "left_speech_bubble", "keywords": ["words", "message", "talk", "chatting"] }, + { "category": "symbols", "char": "🕐", "name": "clock1", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕑", "name": "clock2", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕒", "name": "clock3", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕓", "name": "clock4", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕔", "name": "clock5", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕕", "name": "clock6", "keywords": ["time", "late", "early", "schedule", "dawn", "dusk"] }, + { "category": "symbols", "char": "🕖", "name": "clock7", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕗", "name": "clock8", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕘", "name": "clock9", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕙", "name": "clock10", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕚", "name": "clock11", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕛", "name": "clock12", "keywords": ["time", "noon", "midnight", "midday", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕜", "name": "clock130", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕝", "name": "clock230", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕞", "name": "clock330", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕟", "name": "clock430", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕠", "name": "clock530", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕡", "name": "clock630", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕢", "name": "clock730", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕣", "name": "clock830", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕤", "name": "clock930", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕥", "name": "clock1030", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕦", "name": "clock1130", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕧", "name": "clock1230", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "flags", "char": "🇦🇫", "name": "afghanistan", "keywords": ["af", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇽", "name": "aland_islands", "keywords": ["Åland", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇱", "name": "albania", "keywords": ["al", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇿", "name": "algeria", "keywords": ["dz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇸", "name": "american_samoa", "keywords": ["american", "ws", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇩", "name": "andorra", "keywords": ["ad", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇴", "name": "angola", "keywords": ["ao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇮", "name": "anguilla", "keywords": ["ai", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇶", "name": "antarctica", "keywords": ["aq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇬", "name": "antigua_barbuda", "keywords": ["antigua", "barbuda", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇷", "name": "argentina", "keywords": ["ar", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇲", "name": "armenia", "keywords": ["am", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇼", "name": "aruba", "keywords": ["aw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇨", "name": "ascension_island", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇺", "name": "australia", "keywords": ["au", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇹", "name": "austria", "keywords": ["at", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇿", "name": "azerbaijan", "keywords": ["az", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇸", "name": "bahamas", "keywords": ["bs", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇭", "name": "bahrain", "keywords": ["bh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇩", "name": "bangladesh", "keywords": ["bd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇧", "name": "barbados", "keywords": ["bb", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇾", "name": "belarus", "keywords": ["by", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇪", "name": "belgium", "keywords": ["be", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇿", "name": "belize", "keywords": ["bz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇯", "name": "benin", "keywords": ["bj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇲", "name": "bermuda", "keywords": ["bm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇹", "name": "bhutan", "keywords": ["bt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇴", "name": "bolivia", "keywords": ["bo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇶", "name": "caribbean_netherlands", "keywords": ["bonaire", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇦", "name": "bosnia_herzegovina", "keywords": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇼", "name": "botswana", "keywords": ["bw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇷", "name": "brazil", "keywords": ["br", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇴", "name": "british_indian_ocean_territory", "keywords": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇬", "name": "british_virgin_islands", "keywords": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇳", "name": "brunei", "keywords": ["bn", "darussalam", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇬", "name": "bulgaria", "keywords": ["bg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇫", "name": "burkina_faso", "keywords": ["burkina", "faso", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇮", "name": "burundi", "keywords": ["bi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇻", "name": "cape_verde", "keywords": ["cabo", "verde", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇭", "name": "cambodia", "keywords": ["kh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇲", "name": "cameroon", "keywords": ["cm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇦", "name": "canada", "keywords": ["ca", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇨", "name": "canary_islands", "keywords": ["canary", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇾", "name": "cayman_islands", "keywords": ["cayman", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇫", "name": "central_african_republic", "keywords": ["central", "african", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇩", "name": "chad", "keywords": ["td", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇱", "name": "chile", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇳", "name": "cn", "keywords": ["china", "chinese", "prc", "flag", "country", "nation", "banner"] }, + { "category": "flags", "char": "🇨🇽", "name": "christmas_island", "keywords": ["christmas", "island", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇨", "name": "cocos_islands", "keywords": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇴", "name": "colombia", "keywords": ["co", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇲", "name": "comoros", "keywords": ["km", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇬", "name": "congo_brazzaville", "keywords": ["congo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇩", "name": "congo_kinshasa", "keywords": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇰", "name": "cook_islands", "keywords": ["cook", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇷", "name": "costa_rica", "keywords": ["costa", "rica", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇷", "name": "croatia", "keywords": ["hr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇺", "name": "cuba", "keywords": ["cu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇼", "name": "curacao", "keywords": ["curaçao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇾", "name": "cyprus", "keywords": ["cy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇿", "name": "czech_republic", "keywords": ["cz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇰", "name": "denmark", "keywords": ["dk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇯", "name": "djibouti", "keywords": ["dj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇲", "name": "dominica", "keywords": ["dm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇴", "name": "dominican_republic", "keywords": ["dominican", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇨", "name": "ecuador", "keywords": ["ec", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇬", "name": "egypt", "keywords": ["eg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇻", "name": "el_salvador", "keywords": ["el", "salvador", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇶", "name": "equatorial_guinea", "keywords": ["equatorial", "gn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇷", "name": "eritrea", "keywords": ["er", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇪", "name": "estonia", "keywords": ["ee", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇹", "name": "ethiopia", "keywords": ["et", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇺", "name": "eu", "keywords": ["european", "union", "flag", "banner"] }, + { "category": "flags", "char": "🇫🇰", "name": "falkland_islands", "keywords": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇴", "name": "faroe_islands", "keywords": ["faroe", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇯", "name": "fiji", "keywords": ["fj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇮", "name": "finland", "keywords": ["fi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇷", "name": "fr", "keywords": ["banner", "flag", "nation", "france", "french", "country"] }, + { "category": "flags", "char": "🇬🇫", "name": "french_guiana", "keywords": ["french", "guiana", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇫", "name": "french_polynesia", "keywords": ["french", "polynesia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇫", "name": "french_southern_territories", "keywords": ["french", "southern", "territories", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇦", "name": "gabon", "keywords": ["ga", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇲", "name": "gambia", "keywords": ["gm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇪", "name": "georgia", "keywords": ["ge", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇪", "name": "de", "keywords": ["german", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇬🇭", "name": "ghana", "keywords": ["gh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇮", "name": "gibraltar", "keywords": ["gi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇷", "name": "greece", "keywords": ["gr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇱", "name": "greenland", "keywords": ["gl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇩", "name": "grenada", "keywords": ["gd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇵", "name": "guadeloupe", "keywords": ["gp", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇺", "name": "guam", "keywords": ["gu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇹", "name": "guatemala", "keywords": ["gt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇬", "name": "guernsey", "keywords": ["gg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇳", "name": "guinea", "keywords": ["gn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇼", "name": "guinea_bissau", "keywords": ["gw", "bissau", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇾", "name": "guyana", "keywords": ["gy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇹", "name": "haiti", "keywords": ["ht", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇳", "name": "honduras", "keywords": ["hn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇰", "name": "hong_kong", "keywords": ["hong", "kong", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇺", "name": "hungary", "keywords": ["hu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇸", "name": "iceland", "keywords": ["is", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇳", "name": "india", "keywords": ["in", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇩", "name": "indonesia", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇷", "name": "iran", "keywords": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇶", "name": "iraq", "keywords": ["iq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇪", "name": "ireland", "keywords": ["ie", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇲", "name": "isle_of_man", "keywords": ["isle", "man", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇱", "name": "israel", "keywords": ["il", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇹", "name": "it", "keywords": ["italy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇮", "name": "cote_divoire", "keywords": ["ivory", "coast", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇲", "name": "jamaica", "keywords": ["jm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇵", "name": "jp", "keywords": ["japanese", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇯🇪", "name": "jersey", "keywords": ["je", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇴", "name": "jordan", "keywords": ["jo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇿", "name": "kazakhstan", "keywords": ["kz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇪", "name": "kenya", "keywords": ["ke", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇮", "name": "kiribati", "keywords": ["ki", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇽🇰", "name": "kosovo", "keywords": ["xk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇼", "name": "kuwait", "keywords": ["kw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇬", "name": "kyrgyzstan", "keywords": ["kg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇦", "name": "laos", "keywords": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇻", "name": "latvia", "keywords": ["lv", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇧", "name": "lebanon", "keywords": ["lb", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇸", "name": "lesotho", "keywords": ["ls", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇷", "name": "liberia", "keywords": ["lr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇾", "name": "libya", "keywords": ["ly", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇮", "name": "liechtenstein", "keywords": ["li", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇹", "name": "lithuania", "keywords": ["lt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇺", "name": "luxembourg", "keywords": ["lu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇴", "name": "macau", "keywords": ["macao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇰", "name": "macedonia", "keywords": ["macedonia, ", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇬", "name": "madagascar", "keywords": ["mg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇼", "name": "malawi", "keywords": ["mw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇾", "name": "malaysia", "keywords": ["my", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇻", "name": "maldives", "keywords": ["mv", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇱", "name": "mali", "keywords": ["ml", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇹", "name": "malta", "keywords": ["mt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇭", "name": "marshall_islands", "keywords": ["marshall", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇶", "name": "martinique", "keywords": ["mq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇷", "name": "mauritania", "keywords": ["mr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇺", "name": "mauritius", "keywords": ["mu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇾🇹", "name": "mayotte", "keywords": ["yt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇽", "name": "mexico", "keywords": ["mx", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇲", "name": "micronesia", "keywords": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇩", "name": "moldova", "keywords": ["moldova, ", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇨", "name": "monaco", "keywords": ["mc", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇳", "name": "mongolia", "keywords": ["mn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇪", "name": "montenegro", "keywords": ["me", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇸", "name": "montserrat", "keywords": ["ms", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇦", "name": "morocco", "keywords": ["ma", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇿", "name": "mozambique", "keywords": ["mz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇲", "name": "myanmar", "keywords": ["mm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇦", "name": "namibia", "keywords": ["na", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇷", "name": "nauru", "keywords": ["nr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇵", "name": "nepal", "keywords": ["np", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇱", "name": "netherlands", "keywords": ["nl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇨", "name": "new_caledonia", "keywords": ["new", "caledonia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇿", "name": "new_zealand", "keywords": ["new", "zealand", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇮", "name": "nicaragua", "keywords": ["ni", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇪", "name": "niger", "keywords": ["ne", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇬", "name": "nigeria", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇺", "name": "niue", "keywords": ["nu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇫", "name": "norfolk_island", "keywords": ["norfolk", "island", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇵", "name": "northern_mariana_islands", "keywords": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇵", "name": "north_korea", "keywords": ["north", "korea", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇳🇴", "name": "norway", "keywords": ["no", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇴🇲", "name": "oman", "keywords": ["om_symbol", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇰", "name": "pakistan", "keywords": ["pk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇼", "name": "palau", "keywords": ["pw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇸", "name": "palestinian_territories", "keywords": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇦", "name": "panama", "keywords": ["pa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇬", "name": "papua_new_guinea", "keywords": ["papua", "new", "guinea", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇾", "name": "paraguay", "keywords": ["py", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇪", "name": "peru", "keywords": ["pe", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇭", "name": "philippines", "keywords": ["ph", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇳", "name": "pitcairn_islands", "keywords": ["pitcairn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇱", "name": "poland", "keywords": ["pl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇹", "name": "portugal", "keywords": ["pt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇷", "name": "puerto_rico", "keywords": ["puerto", "rico", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇶🇦", "name": "qatar", "keywords": ["qa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇪", "name": "reunion", "keywords": ["réunion", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇴", "name": "romania", "keywords": ["ro", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇺", "name": "ru", "keywords": ["russian", "federation", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇼", "name": "rwanda", "keywords": ["rw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇱", "name": "st_barthelemy", "keywords": ["saint", "barthélemy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇭", "name": "st_helena", "keywords": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇳", "name": "st_kitts_nevis", "keywords": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇨", "name": "st_lucia", "keywords": ["saint", "lucia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇲", "name": "st_pierre_miquelon", "keywords": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇨", "name": "st_vincent_grenadines", "keywords": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇼🇸", "name": "samoa", "keywords": ["ws", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇲", "name": "san_marino", "keywords": ["san", "marino", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇹", "name": "sao_tome_principe", "keywords": ["sao", "tome", "principe", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇦", "name": "saudi_arabia", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇳", "name": "senegal", "keywords": ["sn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇸", "name": "serbia", "keywords": ["rs", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇨", "name": "seychelles", "keywords": ["sc", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇱", "name": "sierra_leone", "keywords": ["sierra", "leone", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇬", "name": "singapore", "keywords": ["sg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇽", "name": "sint_maarten", "keywords": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇰", "name": "slovakia", "keywords": ["sk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇮", "name": "slovenia", "keywords": ["si", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇧", "name": "solomon_islands", "keywords": ["solomon", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇴", "name": "somalia", "keywords": ["so", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇦", "name": "south_africa", "keywords": ["south", "africa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇸", "name": "south_georgia_south_sandwich_islands", "keywords": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇷", "name": "kr", "keywords": ["south", "korea", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇸🇸", "name": "south_sudan", "keywords": ["south", "sd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇸", "name": "es", "keywords": ["spain", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇰", "name": "sri_lanka", "keywords": ["sri", "lanka", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇩", "name": "sudan", "keywords": ["sd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇷", "name": "suriname", "keywords": ["sr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇿", "name": "swaziland", "keywords": ["sz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇪", "name": "sweden", "keywords": ["se", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇭", "name": "switzerland", "keywords": ["ch", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇾", "name": "syria", "keywords": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇼", "name": "taiwan", "keywords": ["tw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇯", "name": "tajikistan", "keywords": ["tj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇿", "name": "tanzania", "keywords": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇭", "name": "thailand", "keywords": ["th", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇱", "name": "timor_leste", "keywords": ["timor", "leste", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇬", "name": "togo", "keywords": ["tg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇰", "name": "tokelau", "keywords": ["tk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇴", "name": "tonga", "keywords": ["to", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇹", "name": "trinidad_tobago", "keywords": ["trinidad", "tobago", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇦", "name": "tristan_da_cunha", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇳", "name": "tunisia", "keywords": ["tn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇷", "name": "tr", "keywords": ["turkey", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇲", "name": "turkmenistan", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇨", "name": "turks_caicos_islands", "keywords": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇻", "name": "tuvalu", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇬", "name": "uganda", "keywords": ["ug", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇦", "name": "ukraine", "keywords": ["ua", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇪", "name": "united_arab_emirates", "keywords": ["united", "arab", "emirates", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇧", "name": "uk", "keywords": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"] }, + { "category": "flags", "char": "🏴", "name": "england", "keywords": ["flag", "english"] }, + { "category": "flags", "char": "🏴", "name": "scotland", "keywords": ["flag", "scottish"] }, + { "category": "flags", "char": "🏴", "name": "wales", "keywords": ["flag", "welsh"] }, + { "category": "flags", "char": "🇺🇸", "name": "us", "keywords": ["united", "states", "america", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇮", "name": "us_virgin_islands", "keywords": ["virgin", "islands", "us", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇾", "name": "uruguay", "keywords": ["uy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇿", "name": "uzbekistan", "keywords": ["uz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇺", "name": "vanuatu", "keywords": ["vu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇦", "name": "vatican_city", "keywords": ["vatican", "city", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇪", "name": "venezuela", "keywords": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇳", "name": "vietnam", "keywords": ["viet", "nam", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇼🇫", "name": "wallis_futuna", "keywords": ["wallis", "futuna", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇭", "name": "western_sahara", "keywords": ["western", "sahara", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇾🇪", "name": "yemen", "keywords": ["ye", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇲", "name": "zambia", "keywords": ["zm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇼", "name": "zimbabwe", "keywords": ["zw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇳", "name": "united_nations", "keywords": ["un", "flag", "banner"] }, + { "category": "flags", "char": "🏴☠️", "name": "pirate_flag", "keywords": ["skull", "crossbones", "flag", "banner"] } +] diff --git a/packages/client/src/events.ts b/packages/client/src/events.ts new file mode 100644 index 0000000000..dbbd908b8f --- /dev/null +++ b/packages/client/src/events.ts @@ -0,0 +1,4 @@ +import { EventEmitter } from 'eventemitter3'; + +// TODO: 型付け +export const globalEvents = new EventEmitter(); diff --git a/packages/client/src/filters/bytes.ts b/packages/client/src/filters/bytes.ts new file mode 100644 index 0000000000..50e63534b6 --- /dev/null +++ b/packages/client/src/filters/bytes.ts @@ -0,0 +1,9 @@ +export default (v, digits = 0) => { + if (v == null) return '?'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (v == 0) return '0'; + const isMinus = v < 0; + if (isMinus) v = -v; + const i = Math.floor(Math.log(v) / Math.log(1024)); + return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; +}; diff --git a/packages/client/src/filters/note.ts b/packages/client/src/filters/note.ts new file mode 100644 index 0000000000..5c000cf83b --- /dev/null +++ b/packages/client/src/filters/note.ts @@ -0,0 +1,3 @@ +export default note => { + return `/notes/${note.id}`; +}; diff --git a/packages/client/src/filters/number.ts b/packages/client/src/filters/number.ts new file mode 100644 index 0000000000..880a848ca4 --- /dev/null +++ b/packages/client/src/filters/number.ts @@ -0,0 +1 @@ +export default n => n == null ? 'N/A' : n.toLocaleString(); diff --git a/packages/client/src/filters/user.ts b/packages/client/src/filters/user.ts new file mode 100644 index 0000000000..ff2f7e2dae --- /dev/null +++ b/packages/client/src/filters/user.ts @@ -0,0 +1,15 @@ +import * as misskey from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import { url } from '@/config'; + +export const acct = (user: misskey.Acct) => { + return Acct.toString(user); +}; + +export const userName = (user: misskey.entities.User) => { + return user.name || user.username; +}; + +export const userPage = (user: misskey.Acct, path?, absolute = false) => { + return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; +}; diff --git a/packages/client/src/i18n.ts b/packages/client/src/i18n.ts new file mode 100644 index 0000000000..fbc10a0bad --- /dev/null +++ b/packages/client/src/i18n.ts @@ -0,0 +1,13 @@ +import { markRaw } from 'vue'; +import { locale } from '@/config'; +import { I18n } from '@/scripts/i18n'; + +export const i18n = markRaw(new I18n(locale)); + +// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $t: typeof i18n['t']; + $ts: typeof i18n['locale']; + } +} diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts new file mode 100644 index 0000000000..da5b0489ab --- /dev/null +++ b/packages/client/src/init.ts @@ -0,0 +1,420 @@ +/** + * Client entry point + */ + +import '@/style.scss'; + +//#region account indexedDB migration +import { set } from '@/scripts/idb-proxy'; + +if (localStorage.getItem('accounts') != null) { + set('accounts', JSON.parse(localStorage.getItem('accounts'))); + localStorage.removeItem('accounts'); +} +//#endregion + +import * as Sentry from '@sentry/browser'; +import { Integrations } from '@sentry/tracing'; +import { computed, createApp, watch, markRaw, version as vueVersion } from 'vue'; +import compareVersions from 'compare-versions'; + +import widgets from '@/widgets'; +import directives from '@/directives'; +import components from '@/components'; +import { version, ui, lang, host } from '@/config'; +import { router } from '@/router'; +import { applyTheme } from '@/scripts/theme'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { i18n } from '@/i18n'; +import { stream, dialog, post, popup } from '@/os'; +import * as sound from '@/scripts/sound'; +import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; +import { defaultStore, ColdDeviceStorage } from '@/store'; +import { fetchInstance, instance } from '@/instance'; +import { makeHotkey } from '@/scripts/hotkey'; +import { search } from '@/scripts/search'; +import { isMobile } from '@/scripts/is-mobile'; +import { initializeSw } from '@/scripts/initialize-sw'; +import { reloadChannel } from '@/scripts/unison-reload'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { getUrlWithoutLoginId } from '@/scripts/login-id'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; + +console.info(`Misskey v${version}`); + +// boot.jsのやつを解除 +window.onerror = null; +window.onunhandledrejection = null; + +if (_DEV_) { + console.warn('Development mode!!!'); + + console.info(`vue ${vueVersion}`); + + (window as any).$i = $i; + (window as any).$store = defaultStore; + + window.addEventListener('error', event => { + console.error(event); + /* + dialog({ + type: 'error', + title: 'DEV: Unhandled error', + text: event.message + }); + */ + }); + + window.addEventListener('unhandledrejection', event => { + console.error(event); + /* + dialog({ + type: 'error', + title: 'DEV: Unhandled promise rejection', + text: event.reason + }); + */ + }); +} + +if (defaultStore.state.reportError && !_DEV_) { + Sentry.init({ + dsn: 'https://fd273254a07a4b61857607a9ea05d629@o501808.ingest.sentry.io/5583438', + tracesSampleRate: 1.0, + }); + + Sentry.setTag('misskey_version', version); + Sentry.setTag('ui', ui); + Sentry.setTag('lang', lang); + Sentry.setTag('host', host); +} + +// タッチデバイスでCSSの:hoverを機能させる +document.addEventListener('touchend', () => {}, { passive: true }); + +// 一斉リロード +reloadChannel.addEventListener('message', path => { + if (path !== null) location.href = path; + else location.reload(); +}); + +//#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ +// TODO: いつの日にか消したい +const vh = window.innerHeight * 0.01; +document.documentElement.style.setProperty('--vh', `${vh}px`); +window.addEventListener('resize', () => { + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); +}); +//#endregion + +// If mobile, insert the viewport meta tag +if (isMobile || window.innerWidth <= 1024) { + const viewport = document.getElementsByName('viewport').item(0); + viewport.setAttribute('content', + `${viewport.getAttribute('content')},minimum-scale=1,maximum-scale=1,user-scalable=no`); + document.head.appendChild(viewport); +} + +//#region Set lang attr +const html = document.documentElement; +html.setAttribute('lang', lang); +//#endregion + +//#region loginId +const params = new URLSearchParams(location.search); +const loginId = params.get('loginId'); + +if (loginId) { + const target = getUrlWithoutLoginId(location.href); + + if (!$i || $i.id !== loginId) { + const account = await getAccountFromId(loginId); + if (account) { + await login(account.token, target); + } + } + + history.replaceState({ misskey: 'loginId' }, '', target); +} + +//#endregion + +//#region Fetch user +if ($i && $i.token) { + if (_DEV_) { + console.log('account cache found. refreshing...'); + } + + refreshAccount(); +} else { + if (_DEV_) { + console.log('no account cache found.'); + } + + // 連携ログインの場合用にCookieを参照する + const i = (document.cookie.match(/igi=(\w+)/) || [null, null])[1]; + + if (i != null && i !== 'null') { + if (_DEV_) { + console.log('signing...'); + } + + try { + document.body.innerHTML = '<div>Please wait...</div>'; + await login(i); + location.reload(); + } catch (e) { + // Render the error screen + // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) + document.body.innerHTML = '<div id="err">Oops!</div>'; + } + } else { + if (_DEV_) { + console.log('not signed in'); + } + } +} +//#endregion + +fetchInstance().then(() => { + localStorage.setItem('v', instance.version); + + // Init service worker + initializeSw(); +}); + +const app = createApp(await ( + window.location.search === '?zen' ? import('@/ui/zen.vue') : + !$i ? import('@/ui/visitor.vue') : + ui === 'deck' ? import('@/ui/deck.vue') : + ui === 'desktop' ? import('@/ui/desktop.vue') : + ui === 'chat' ? import('@/ui/chat/index.vue') : + ui === 'classic' ? import('@/ui/classic.vue') : + import('@/ui/universal.vue') +).then(x => x.default)); + +if (_DEV_) { + app.config.performance = true; +} + +app.config.globalProperties = { + $i, + $store: defaultStore, + $instance: instance, + $t: i18n.t, + $ts: i18n.locale, +}; + +app.use(router); + +widgets(app); +directives(app); +components(app); + +await router.isReady(); + +const splash = document.getElementById('splash'); +// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) +if (splash) splash.addEventListener('transitionend', () => { + splash.remove(); +}); + +const rootEl = document.createElement('div'); +document.body.appendChild(rootEl); +app.mount(rootEl); + +reactionPicker.init(); + +if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; +} + +// クライアントが更新されたか? +const lastVersion = localStorage.getItem('lastVersion'); +if (lastVersion !== version) { + localStorage.setItem('lastVersion', version); + + // テーマリビルドするため + localStorage.removeItem('theme'); + + try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため + if (lastVersion != null && compareVersions(version, lastVersion) === 1) { + // ログインしてる場合だけ + if ($i) { + popup(import('@/components/updated.vue'), {}, {}, 'closed'); + } + } + } catch (e) { + } +} + +// NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) +watch(defaultStore.reactiveState.darkMode, (darkMode) => { + applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); +}, { immediate: localStorage.theme == null }); + +const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); +const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); + +watch(darkTheme, (theme) => { + if (defaultStore.state.darkMode) { + applyTheme(theme); + } +}); + +watch(lightTheme, (theme) => { + if (!defaultStore.state.darkMode) { + applyTheme(theme); + } +}); + +//#region Sync dark mode +if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', isDeviceDarkmode()); +} + +window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', mql.matches); + } +}); +//#endregion + +// shortcut +document.addEventListener('keydown', makeHotkey({ + 'd': () => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 'p|n': post, + 's': search, + //TODO: 'h|/': help +})); + +watch(defaultStore.reactiveState.useBlurEffectForModal, v => { + document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); +}, { immediate: true }); + +watch(defaultStore.reactiveState.useBlurEffect, v => { + if (v) { + document.documentElement.style.removeProperty('--blur'); + } else { + document.documentElement.style.setProperty('--blur', 'none'); + } +}, { immediate: true }); + +let reloadDialogShowing = false; +stream.on('_disconnected_', async () => { + if (defaultStore.state.serverDisconnectedBehavior === 'reload') { + location.reload(); + } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await dialog({ + type: 'warning', + title: i18n.locale.disconnectedFromServer, + text: i18n.locale.reloadConfirm, + showCancelButton: true + }); + reloadDialogShowing = false; + if (!canceled) { + location.reload(); + } + } +}); + +stream.on('emojiAdded', data => { + // TODO + //store.commit('instance/set', ); +}); + +for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { + import('./plugin').then(({ install }) => { + install(plugin); + }); +} + +if ($i) { + if ($i.isDeleted) { + dialog({ + type: 'warning', + text: i18n.locale.accountDeletionInProgress, + }); + } + + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + } + + const main = markRaw(stream.useChannel('main', null, 'System')); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + updateAccount(i); + }); + + main.on('readAllNotifications', () => { + updateAccount({ hasUnreadNotification: false }); + }); + + main.on('unreadNotification', () => { + updateAccount({ hasUnreadNotification: true }); + }); + + main.on('unreadMention', () => { + updateAccount({ hasUnreadMentions: true }); + }); + + main.on('readAllUnreadMentions', () => { + updateAccount({ hasUnreadMentions: false }); + }); + + main.on('unreadSpecifiedNote', () => { + updateAccount({ hasUnreadSpecifiedNotes: true }); + }); + + main.on('readAllUnreadSpecifiedNotes', () => { + updateAccount({ hasUnreadSpecifiedNotes: false }); + }); + + main.on('readAllMessagingMessages', () => { + updateAccount({ hasUnreadMessagingMessage: false }); + }); + + main.on('unreadMessagingMessage', () => { + updateAccount({ hasUnreadMessagingMessage: true }); + sound.play('chatBg'); + }); + + main.on('readAllAntennas', () => { + updateAccount({ hasUnreadAntenna: false }); + }); + + main.on('unreadAntenna', () => { + updateAccount({ hasUnreadAntenna: true }); + sound.play('antenna'); + }); + + main.on('readAllAnnouncements', () => { + updateAccount({ hasUnreadAnnouncement: false }); + }); + + main.on('readAllChannels', () => { + updateAccount({ hasUnreadChannel: false }); + }); + + main.on('unreadChannel', () => { + updateAccount({ hasUnreadChannel: true }); + sound.play('channel'); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + signout(); + }); +} diff --git a/packages/client/src/instance.ts b/packages/client/src/instance.ts new file mode 100644 index 0000000000..6e912aa2e5 --- /dev/null +++ b/packages/client/src/instance.ts @@ -0,0 +1,52 @@ +import { computed, reactive } from 'vue'; +import * as Misskey from 'misskey-js'; +import { api } from './os'; + +// TODO: 他のタブと永続化されたstateを同期 + +const data = localStorage.getItem('instance'); + +// TODO: instanceをリアクティブにするかは再考の余地あり + +export const instance: Misskey.entities.InstanceMetadata = reactive(data ? JSON.parse(data) : { + // TODO: set default values +}); + +export async function fetchInstance() { + const meta = await api('meta', { + detail: false + }); + + for (const [k, v] of Object.entries(meta)) { + instance[k] = v; + } + + localStorage.setItem('instance', JSON.stringify(instance)); +} + +export const emojiCategories = computed(() => { + if (instance.emojis == null) return []; + const categories = new Set(); + for (const emoji of instance.emojis) { + categories.add(emoji.category); + } + return Array.from(categories); +}); + +export const emojiTags = computed(() => { + if (instance.emojis == null) return []; + const tags = new Set(); + for (const emoji of instance.emojis) { + for (const tag of emoji.aliases) { + tags.add(tag); + } + } + return Array.from(tags); +}); + +// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $instance: typeof instance; + } +} diff --git a/packages/client/src/menu.ts b/packages/client/src/menu.ts new file mode 100644 index 0000000000..ae74740bb8 --- /dev/null +++ b/packages/client/src/menu.ts @@ -0,0 +1,224 @@ +import { computed, ref } from 'vue'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { ui } from '@/config'; +import { $i } from './account'; +import { unisonReload } from '@/scripts/unison-reload'; +import { router } from './router'; + +export const menuDef = { + notifications: { + title: 'notifications', + icon: 'fas fa-bell', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadNotification), + to: '/my/notifications', + }, + messaging: { + title: 'messaging', + icon: 'fas fa-comments', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage), + to: '/my/messaging', + }, + drive: { + title: 'drive', + icon: 'fas fa-cloud', + show: computed(() => $i != null), + to: '/my/drive', + }, + followRequests: { + title: 'followRequests', + icon: 'fas fa-user-clock', + show: computed(() => $i != null && $i.isLocked), + indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), + to: '/my/follow-requests', + }, + featured: { + title: 'featured', + icon: 'fas fa-fire-alt', + to: '/featured', + }, + explore: { + title: 'explore', + icon: 'fas fa-hashtag', + to: '/explore', + }, + announcements: { + title: 'announcements', + icon: 'fas fa-broadcast-tower', + indicated: computed(() => $i != null && $i.hasUnreadAnnouncement), + to: '/announcements', + }, + search: { + title: 'search', + icon: 'fas fa-search', + action: () => search(), + }, + lists: { + title: 'lists', + icon: 'fas fa-list-ul', + show: computed(() => $i != null), + active: computed(() => router.currentRoute.value.path.startsWith('/timeline/list/') || router.currentRoute.value.path === '/my/lists' || router.currentRoute.value.path.startsWith('/my/lists/')), + action: (ev) => { + const items = ref([{ + type: 'pending' + }]); + os.api('users/lists/list').then(lists => { + const _items = [...lists.map(list => ({ + type: 'link', + text: list.name, + to: `/timeline/list/${list.id}` + })), null, { + type: 'link', + to: '/my/lists', + text: i18n.locale.manageLists, + icon: 'fas fa-cog', + }]; + items.value = _items; + }); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + }, + groups: { + title: 'groups', + icon: 'fas fa-users', + show: computed(() => $i != null), + to: '/my/groups', + }, + antennas: { + title: 'antennas', + icon: 'fas fa-satellite', + show: computed(() => $i != null), + active: computed(() => router.currentRoute.value.path.startsWith('/timeline/antenna/') || router.currentRoute.value.path === '/my/antennas' || router.currentRoute.value.path.startsWith('/my/antennas/')), + action: (ev) => { + const items = ref([{ + type: 'pending' + }]); + os.api('antennas/list').then(antennas => { + const _items = [...antennas.map(antenna => ({ + type: 'link', + text: antenna.name, + to: `/timeline/antenna/${antenna.id}` + })), null, { + type: 'link', + to: '/my/antennas', + text: i18n.locale.manageAntennas, + icon: 'fas fa-cog', + }]; + items.value = _items; + }); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + }, + mentions: { + title: 'mentions', + icon: 'fas fa-at', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadMentions), + to: '/my/mentions', + }, + messages: { + title: 'directNotes', + icon: 'fas fa-envelope', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadSpecifiedNotes), + to: '/my/messages', + }, + favorites: { + title: 'favorites', + icon: 'fas fa-star', + show: computed(() => $i != null), + to: '/my/favorites', + }, + pages: { + title: 'pages', + icon: 'fas fa-file-alt', + to: '/pages', + }, + gallery: { + title: 'gallery', + icon: 'fas fa-icons', + to: '/gallery', + }, + clips: { + title: 'clip', + icon: 'fas fa-paperclip', + show: computed(() => $i != null), + to: '/my/clips', + }, + channels: { + title: 'channel', + icon: 'fas fa-satellite-dish', + to: '/channels', + }, + federation: { + title: 'federation', + icon: 'fas fa-globe', + to: '/federation', + }, + emojis: { + title: 'emojis', + icon: 'fas fa-laugh', + to: '/emojis', + }, + games: { + title: 'games', + icon: 'fas fa-gamepad', + to: '/games/reversi', + }, + scratchpad: { + title: 'scratchpad', + icon: 'fas fa-terminal', + to: '/scratchpad', + }, + rooms: { + title: 'rooms', + icon: 'fas fa-door-closed', + show: computed(() => $i != null), + to: computed(() => `/@${$i.username}/room`), + }, + ui: { + title: 'switchUi', + icon: 'fas fa-columns', + action: (ev) => { + os.popupMenu([{ + text: i18n.locale.default, + active: ui === 'default' || ui === null, + action: () => { + localStorage.setItem('ui', 'default'); + unisonReload(); + } + }, { + text: i18n.locale.deck, + active: ui === 'deck', + action: () => { + localStorage.setItem('ui', 'deck'); + unisonReload(); + } + }, { + text: i18n.locale.classic, + active: ui === 'classic', + action: () => { + localStorage.setItem('ui', 'classic'); + unisonReload(); + } + }, { + text: 'Chat (β)', + active: ui === 'chat', + action: () => { + localStorage.setItem('ui', 'chat'); + unisonReload(); + } + }, /*{ + text: i18n.locale.desktop + ' (β)', + active: ui === 'desktop', + action: () => { + localStorage.setItem('ui', 'desktop'); + unisonReload(); + } + }*/], ev.currentTarget || ev.target); + }, + }, +}; diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts new file mode 100644 index 0000000000..a570ffc9ed --- /dev/null +++ b/packages/client/src/os.ts @@ -0,0 +1,501 @@ +// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する + +import { Component, defineAsyncComponent, markRaw, reactive, Ref, ref } from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import * as Misskey from 'misskey-js'; +import * as Sentry from '@sentry/browser'; +import { apiUrl, debug, url } from '@/config'; +import MkPostFormDialog from '@/components/post-form-dialog.vue'; +import MkWaitingDialog from '@/components/waiting-dialog.vue'; +import { resolve } from '@/router'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; + +export const stream = markRaw(new Misskey.Stream(url, $i)); + +export const pendingApiRequestsCount = ref(0); +let apiRequestsCount = 0; // for debug +export const apiRequests = ref([]); // for debug + +export const windows = new Map(); + +const apiClient = new Misskey.api.APIClient({ + origin: url, +}); + +export const api = ((endpoint: string, data: Record<string, any> = {}, token?: string | null | undefined) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const log = debug ? reactive({ + id: ++apiRequestsCount, + endpoint, + req: markRaw(data), + res: null, + state: 'pending', + }) : null; + if (debug) { + apiRequests.value.push(log); + if (apiRequests.value.length > 128) apiRequests.value.shift(); + } + + const promise = new Promise((resolve, reject) => { + // Append a credential + if ($i) (data as any).i = $i.token; + if (token !== undefined) (data as any).i = token; + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + if (debug) { + log!.res = markRaw(JSON.parse(JSON.stringify(body))); + log!.state = 'success'; + } + } else if (res.status === 204) { + resolve(); + if (debug) { + log!.state = 'success'; + } + } else { + reject(body.error); + if (debug) { + log!.res = markRaw(body.error); + log!.state = 'failed'; + } + + if (defaultStore.state.reportError && !_DEV_) { + Sentry.withScope((scope) => { + scope.setTag('api_endpoint', endpoint); + scope.setContext('api params', data); + scope.setContext('api error info', body.info); + scope.setTag('api_error_id', body.id); + scope.setTag('api_error_code', body.code); + scope.setTag('api_error_kind', body.kind); + scope.setLevel(Sentry.Severity.Error); + Sentry.captureMessage('API error'); + }); + } + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + +export const apiWithDialog = (( + endpoint: string, + data: Record<string, any> = {}, + token?: string | null | undefined, +) => { + const promise = api(endpoint, data, token); + promiseDialog(promise, null, (e) => { + dialog({ + type: 'error', + text: e.message + '\n' + (e as any).id, + }); + }); + + return promise; +}) as typeof api; + +export function promiseDialog<T extends Promise<any>>( + promise: T, + onSuccess?: ((res: any) => void) | null, + onFailure?: ((e: Error) => void) | null, + text?: string, +): T { + const showing = ref(true); + const success = ref(false); + + promise.then(res => { + if (onSuccess) { + showing.value = false; + onSuccess(res); + } else { + success.value = true; + setTimeout(() => { + showing.value = false; + }, 1000); + } + }).catch(e => { + showing.value = false; + if (onFailure) { + onFailure(e); + } else { + dialog({ + type: 'error', + text: e + }); + } + }); + + // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない) + popup(MkWaitingDialog, { + success: success, + showing: showing, + text: text, + }, {}, 'closed'); + + return promise; +} + +function isModule(x: any): x is typeof import('*.vue') { + return x.default != null; +} + +let popupIdCount = 0; +export const popups = ref([]) as Ref<{ + id: any; + component: any; + props: Record<string, any>; +}[]>; + +export async function popup(component: Component | typeof import('*.vue') | Promise<Component | typeof import('*.vue')>, props: Record<string, any>, events = {}, disposeEvent?: string) { + if (component.then) component = await component; + + if (isModule(component)) component = component.default; + markRaw(component); + + const id = ++popupIdCount; + const dispose = () => { + if (_DEV_) console.log('os:popup close', id, component, props, events); + // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ? + setTimeout(() => { + popups.value = popups.value.filter(popup => popup.id !== id); + }, 0); + }; + const state = { + component, + props, + events: disposeEvent ? { + ...events, + [disposeEvent]: dispose + } : events, + id, + }; + + if (_DEV_) console.log('os:popup open', id, component, props, events); + popups.value.push(state); + + return { + dispose, + }; +} + +export function pageWindow(path: string) { + const { component, props } = resolve(path); + popup(import('@/components/page-window.vue'), { + initialPath: path, + initialComponent: markRaw(component), + initialProps: props, + }, {}, 'closed'); +} + +export function modalPageWindow(path: string) { + const { component, props } = resolve(path); + popup(import('@/components/modal-page-window.vue'), { + initialPath: path, + initialComponent: markRaw(component), + initialProps: props, + }, {}, 'closed'); +} + +export function dialog(props: { + type: 'error' | 'info' | 'success' | 'warning' | 'waiting'; + title?: string | null; + text?: string | null; +}) { + return new Promise((resolve, reject) => { + popup(import('@/components/dialog.vue'), props, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function success() { + return new Promise((resolve, reject) => { + const showing = ref(true); + setTimeout(() => { + showing.value = false; + }, 1000); + popup(import('@/components/waiting-dialog.vue'), { + success: true, + showing: showing + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function waiting() { + return new Promise((resolve, reject) => { + const showing = ref(true); + popup(import('@/components/waiting-dialog.vue'), { + success: false, + showing: showing + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function form(title, form) { + return new Promise((resolve, reject) => { + popup(import('@/components/form-dialog.vue'), { title, form }, { + done: result => { + resolve(result); + }, + }, 'closed'); + }); +} + +export async function selectUser() { + return new Promise((resolve, reject) => { + popup(import('@/components/user-select-dialog.vue'), {}, { + ok: user => { + resolve(user); + }, + }, 'closed'); + }); +} + +export async function selectDriveFile(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(import('@/components/drive-select-dialog.vue'), { + type: 'file', + multiple + }, { + done: files => { + if (files) { + resolve(multiple ? files : files[0]); + } + }, + }, 'closed'); + }); +} + +export async function selectDriveFolder(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(import('@/components/drive-select-dialog.vue'), { + type: 'folder', + multiple + }, { + done: folders => { + if (folders) { + resolve(multiple ? folders : folders[0]); + } + }, + }, 'closed'); + }); +} + +export async function pickEmoji(src?: HTMLElement, opts) { + return new Promise((resolve, reject) => { + popup(import('@/components/emoji-picker-dialog.vue'), { + src, + ...opts + }, { + done: emoji => { + resolve(emoji); + }, + }, 'closed'); + }); +} + +type AwaitType<T> = + T extends Promise<infer U> ? U : + T extends (...args: any[]) => Promise<infer V> ? V : + T; +let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | null = null; +let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; +export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) { + if (openingEmojiPicker) return; + + activeTextarea = initialTextarea; + + const textareas = document.querySelectorAll('textarea, input'); + for (const textarea of Array.from(textareas)) { + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + + const observer = new MutationObserver(records => { + for (const record of records) { + for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) { + const textareas = node.querySelectorAll('textarea, input') as NodeListOf<NonNullable<typeof activeTextarea>>; + for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) { + if (document.activeElement === textarea) activeTextarea = textarea; + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }); + + openingEmojiPicker = await popup(import('@/components/emoji-picker-window.vue'), { + src, + ...opts + }, { + chosen: emoji => { + insertTextAtCursor(activeTextarea, emoji); + }, + closed: () => { + openingEmojiPicker!.dispose(); + openingEmojiPicker = null; + observer.disconnect(); + } + }); +} + +export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: { + align?: string; + width?: number; + viaKeyboard?: boolean; +}) { + return new Promise((resolve, reject) => { + let dispose; + popup(import('@/components/ui/popup-menu.vue'), { + items, + src, + width: options?.width, + align: options?.align, + viaKeyboard: options?.viaKeyboard + }, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export function contextMenu(items: any[], ev: MouseEvent) { + ev.preventDefault(); + return new Promise((resolve, reject) => { + let dispose; + popup(import('@/components/ui/context-menu.vue'), { + items, + ev, + }, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export function post(props: Record<string, any>) { + return new Promise((resolve, reject) => { + // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない + // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 + // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 + // 複数のpost formを開いたときに場合によってはエラーになる + // もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが + let dispose; + popup(MkPostFormDialog, props, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export const deckGlobalEvents = new EventEmitter(); + +export const uploads = ref([]); + +export function upload(file: File, folder?: any, name?: string) { + if (folder && typeof folder == 'object') folder = folder.id; + + return new Promise((resolve, reject) => { + const id = Math.random(); + + const reader = new FileReader(); + reader.onload = (e) => { + const ctx = reactive({ + id: id, + name: name || file.name || 'untitled', + progressMax: undefined, + progressValue: undefined, + img: window.URL.createObjectURL(file) + }); + + uploads.value.push(ctx); + + const data = new FormData(); + data.append('i', $i.token); + data.append('force', 'true'); + data.append('file', file); + + if (folder) data.append('folderId', folder); + if (name) data.append('name', name); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = (e: any) => { + const driveFile = JSON.parse(e.target.response); + + resolve(driveFile); + + uploads.value = uploads.value.filter(x => x.id != id); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) { + ctx.progressMax = e.total; + ctx.progressValue = e.loaded; + } + }; + + xhr.send(data); + }; + reader.readAsArrayBuffer(file); + }); +} + +/* +export function checkExistence(fileData: ArrayBuffer): Promise<any> { + return new Promise((resolve, reject) => { + const data = new FormData(); + data.append('md5', getMD5(fileData)); + + os.api('drive/files/find-by-hash', { + md5: getMD5(fileData) + }).then(resp => { + resolve(resp.length > 0 ? resp[0] : null); + }); + }); +}*/ diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue new file mode 100644 index 0000000000..c549751a27 --- /dev/null +++ b/packages/client/src/pages/_error_.vue @@ -0,0 +1,94 @@ +<template> +<MkLoading v-if="!loaded" /> +<transition :name="$store.state.animation ? 'zoom' : ''" appear> + <div class="mjndxjch" v-show="loaded"> + <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p><b><i class="fas fa-exclamation-triangle"></i> {{ $ts.pageLoadError }}</b></p> + <p v-if="version === meta.version">{{ $ts.pageLoadErrorDescription }}</p> + <p v-else-if="serverIsDead">{{ $ts.serverIsDead }}</p> + <template v-else> + <p>{{ $ts.newVersionOfClientAvailable }}</p> + <p>{{ $ts.youShouldUpgradeClient }}</p> + <MkButton @click="reload" class="button primary">{{ $ts.reload }}</MkButton> + </template> + <p><MkA to="/docs/general/troubleshooting" class="_link">{{ $ts.troubleshooting }}</MkA></p> + <p v-if="error" class="error">ERROR: {{ error }}</p> + </div> +</transition> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as symbols from '@/symbols'; +import { version } from '@/config'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; + +export default defineComponent({ + components: { + MkButton, + }, + props: { + error: { + required: false, + } + }, + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.error, + icon: 'fas fa-exclamation-triangle' + }, + loaded: false, + serverIsDead: false, + meta: {} as any, + version, + }; + }, + created() { + os.api('meta', { + detail: false + }).then(meta => { + this.loaded = true; + this.serverIsDead = false; + this.meta = meta; + localStorage.setItem('v', meta.version); + }, () => { + this.loaded = true; + this.serverIsDead = true; + }); + }, + methods: { + reload() { + unisonReload(); + }, + }, +}); +</script> + +<style lang="scss" scoped> +.mjndxjch { + padding: 32px; + text-align: center; + + > p { + margin: 0 0 12px 0; + } + + > .button { + margin: 8px auto; + } + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 24px; + border-radius: 16px; + } + + > .error { + opacity: 0.7; + } +} +</style> diff --git a/packages/client/src/pages/_loading_.vue b/packages/client/src/pages/_loading_.vue new file mode 100644 index 0000000000..05c6af1cd7 --- /dev/null +++ b/packages/client/src/pages/_loading_.vue @@ -0,0 +1,10 @@ +<template> +<MkLoading/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({}); +</script> diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue new file mode 100644 index 0000000000..c428c1ad83 --- /dev/null +++ b/packages/client/src/pages/about-misskey.vue @@ -0,0 +1,238 @@ +<template> +<div style="overflow: clip;"> + <FormBase class="znqjceqz"> + <div id="debug"></div> + <section class="_debobigegoItem about"> + <div class="_debobigegoPanel panel" :class="{ playing: easterEggEngine != null }" ref="about"> + <img src="/client-assets/about-icon.png" alt="" class="icon" @load="iconLoaded" draggable="false" @click="gravity"/> + <div class="misskey">Misskey</div> + <div class="version">v{{ version }}</div> + <span class="emoji" v-for="emoji in easterEggEmojis" :key="emoji.id" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span> + </div> + </section> + <section class="_debobigegoItem" style="text-align: center; padding: 0 16px;"> + {{ $ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ $ts.learnMore }}</a> + </section> + <FormGroup> + <FormLink to="https://github.com/misskey-dev/misskey" external> + <template #icon><i class="fas fa-code"></i></template> + {{ $ts._aboutMisskey.source }} + <template #suffix>GitHub</template> + </FormLink> + <FormLink to="https://crowdin.com/project/misskey" external> + <template #icon><i class="fas fa-language"></i></template> + {{ $ts._aboutMisskey.translation }} + <template #suffix>Crowdin</template> + </FormLink> + <FormLink to="https://www.patreon.com/syuilo" external> + <template #icon><i class="fas fa-hand-holding-medical"></i></template> + {{ $ts._aboutMisskey.donate }} + <template #suffix>Patreon</template> + </FormLink> + </FormGroup> + <FormGroup> + <template #label>{{ $ts._aboutMisskey.contributors }}</template> + <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink> + <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink> + <FormLink to="https://github.com/mei23" external>@mei23</FormLink> + <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink> + <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink> + <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink> + <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink> + <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink> + <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink> + <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ $ts._aboutMisskey.allContributors }}</MkLink></template> + </FormGroup> + <FormGroup> + <template #label><Mfm text="[jelly ❤]"/> {{ $ts._aboutMisskey.patrons }}</template> + <FormKeyValueView v-for="patron in patrons" :key="patron"><template #key>{{ patron }}</template></FormKeyValueView> + <template #caption>{{ $ts._aboutMisskey.morePatrons }}</template> + </FormGroup> + </FormBase> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { version } from '@/config'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import MkLink from '@/components/link.vue'; +import { physics } from '@/scripts/physics'; +import * as symbols from '@/symbols'; + +const patrons = [ + 'Satsuki Yanagi', + 'noellabo', + 'mametsuko', + 'AureoleArk', + 'Gargron', + 'Nokotaro Takeda', + 'Suji Yan', + 'Hekovic', + 'Gitmo Life Services', + 'nenohi', + 'naga_rus', + 'Melilot', + 'Efertone', + 'oi_yekssim', + 'nanami kan', + 'motcha', + 'dansup', + 'Quinton Macejkovic', + 'YUKIMOCHI', + 'mewl hayabusa', + 'makokunsan', + 'Peter G.', + 'Nesakko', + 'regtan', + '見当かなみ', + 'natalie', + 'Jerry', + 'takimura', + 'sikyosyounin', + 'YuzuRyo61', + 'sheeta.s', + 'osapon', + 'mkatze', + 'CG', + 'nafuchoco', + 'Takumi Sugita', + 'chidori ninokura', + 'mydarkstar', + 'kiritan', + 'kabo2468y', + 'weepjp', + 'Liaizon Wakest', + 'Steffen K9', + 'Roujo', + 'uroco @99', + 'totokoro', + 'public_yusuke', + 'wara', + 'S Y', + 'Denshi', + 'Osushimaru', + '吴浥', + 'DignifiedSilence', + 't_w', +]; + +export default defineComponent({ + components: { + FormBase, + FormGroup, + FormLink, + FormKeyValueView, + MkLink, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.aboutMisskey, + icon: null + }, + version, + patrons, + easterEggReady: false, + easterEggEmojis: [], + easterEggEngine: null, + } + }, + + beforeUnmount() { + if (this.easterEggEngine) { + this.easterEggEngine.stop(); + } + }, + + methods: { + iconLoaded() { + const emojis = this.$store.state.reactions; + const containerWidth = this.$refs.about.offsetWidth; + for (let i = 0; i < 32; i++) { + this.easterEggEmojis.push({ + id: i.toString(), + top: -(128 + (Math.random() * 256)), + left: (Math.random() * containerWidth), + emoji: emojis[Math.floor(Math.random() * emojis.length)], + }); + } + + this.$nextTick(() => { + this.easterEggReady = true; + }); + }, + + gravity() { + if (!this.easterEggReady) return; + this.easterEggReady = false; + this.easterEggEngine = physics(this.$refs.about); + } + } +}); +</script> + +<style lang="scss" scoped> +.znqjceqz { + max-width: 800px; + box-sizing: border-box; + margin: 0 auto; + + > .about { + > .panel { + position: relative; + text-align: center; + padding: 16px; + + &.playing { + &, * { + user-select: none; + } + + * { + will-change: transform; + } + + > .emoji { + visibility: visible; + } + } + + > .icon { + display: block; + width: 100px; + margin: 0 auto; + border-radius: 16px; + } + + > .misskey { + margin: 0.75em auto 0 auto; + width: max-content; + } + + > .version { + margin: 0 auto; + width: max-content; + opacity: 0.5; + } + + > .emoji { + position: absolute; + top: 0; + left: 0; + visibility: hidden; + + > .emoji { + pointer-events: none; + font-size: 24px; + width: 24px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue new file mode 100644 index 0000000000..dbdf0f6d91 --- /dev/null +++ b/packages/client/src/pages/about.vue @@ -0,0 +1,123 @@ +<template> +<FormBase> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel fwhjspax"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <span class="name">{{ $instance.name || host }}</span> + </div> + </div> + + <FormTextarea readonly :value="$instance.description"> + </FormTextarea> + + <FormGroup> + <FormKeyValueView> + <template #key>Misskey</template> + <template #value>v{{ version }}</template> + </FormKeyValueView> + <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink> + </FormGroup> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.administrator }}</template> + <template #value>{{ $instance.maintainerName }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.contact }}</template> + <template #value>{{ $instance.maintainerEmail }}</template> + </FormKeyValueView> + </FormGroup> + + <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" external>{{ $ts.tos }}</FormLink> + + <FormSuspense :p="initStats"> + <FormGroup> + <template #label>{{ $ts.statistics }}</template> + <FormKeyValueView> + <template #key>{{ $ts.users }}</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.notes }}</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> + + <FormGroup> + <template #label>Well-known resources</template> + <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> + <FormLink :to="`/robots.txt`" external>robots.txt</FormLink> + <FormLink :to="`/manifest.json`" external>manifest.json</FormLink> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { version, instanceName } from '@/config'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import * as symbols from '@/symbols'; +import { host } from '@/config'; + +export default defineComponent({ + components: { + FormBase, + FormGroup, + FormLink, + FormKeyValueView, + FormTextarea, + FormSuspense, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceInfo, + icon: 'fas fa-info-circle' + }, + host, + version, + instanceName, + stats: null, + initStats: () => os.api('stats', { + }).then((stats) => { + this.stats = stats; + }) + } + }, + + methods: { + number + } +}); +</script> + +<style lang="scss" scoped> +.fwhjspax { + padding: 16px; + text-align: center; + + > .icon { + display: block; + margin: auto; + height: 64px; + border-radius: 8px; + } + + > .name { + display: block; + margin-top: 12px; + } +} +</style> diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue new file mode 100644 index 0000000000..ca94737781 --- /dev/null +++ b/packages/client/src/pages/admin/abuses.vue @@ -0,0 +1,170 @@ +<template> +<div class="lcixvhis"> + <div class="_section reports"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="unresolved">{{ $ts.unresolved }}</option> + <option value="resolved">{{ $ts.resolved }}</option> + </MkSelect> + <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.reporteeOrigin }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.reporterOrigin }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + </div> + <!-- TODO + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()"> + <span>{{ $ts.username }}</span> + </MkInput> + <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'"> + <span>{{ $ts.host }}</span> + </MkInput> + </div> + --> + + <MkPagination :pagination="pagination" #default="{items}" ref="reports" style="margin-top: var(--margin);"> + <div class="bcekxzvu _card _gap" v-for="report in items" :key="report.id"> + <div class="_content target"> + <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> + <div class="info"> + <MkUserName class="name" :user="report.targetUser"/> + <div class="acct">@{{ acct(report.targetUser) }}</div> + </div> + </div> + <div class="_content"> + <div> + <Mfm :text="report.comment"/> + </div> + <hr> + <div>Reporter: <MkAcct :user="report.reporter"/></div> + <div><MkTime :time="report.createdAt"/></div> + </div> + <div class="_footer"> + <div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div> + <MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $ts.abuseMarkAsResolved }}</MkButton> + </div> + </div> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.abuseReports, + icon: 'fas fa-exclamation-circle', + bg: 'var(--bg)', + }, + searchUsername: '', + searchHost: '', + state: 'unresolved', + reporterOrigin: 'combined', + targetUserOrigin: 'combined', + pagination: { + endpoint: 'admin/abuse-user-reports', + limit: 10, + params: () => ({ + state: this.state, + reporterOrigin: this.reporterOrigin, + targetUserOrigin: this.targetUserOrigin, + }), + }, + } + }, + + watch: { + state() { + this.$refs.reports.reload(); + }, + + reporterOrigin() { + this.$refs.reports.reload(); + }, + + targetUserOrigin() { + this.$refs.reports.reload(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + acct, + + resolve(report) { + os.apiWithDialog('admin/resolve-abuse-user-report', { + reportId: report.id, + }).then(() => { + this.$refs.reports.removeItem(item => item.id === report.id); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.lcixvhis { + margin: var(--margin); +} + +.bcekxzvu { + > .target { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + > .avatar { + width: 42px; + height: 42px; + } + + > .info { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + > .name { + font-weight: bold; + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue new file mode 100644 index 0000000000..df6c9d5d00 --- /dev/null +++ b/packages/client/src/pages/admin/ads.vue @@ -0,0 +1,138 @@ +<template> +<div class="uqshojas"> + <section class="_card _gap ads" v-for="ad in ads"> + <div class="_content ad"> + <MkAd v-if="ad.url" :specify="ad"/> + <MkInput v-model="ad.url" type="url"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="ad.imageUrl"> + <template #label>{{ $ts.imageUrl }}</template> + </MkInput> + <div style="margin: 32px 0;"> + <MkRadio v-model="ad.place" value="square">square</MkRadio> + <MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio> + <MkRadio v-model="ad.place" value="horizontal-big">horizontal-big</MkRadio> + </div> + <!-- + <div style="margin: 32px 0;"> + {{ $ts.priority }} + <MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio> + <MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio> + <MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio> + </div> + --> + <MkInput v-model="ad.ratio" type="number"> + <template #label>{{ $ts.ratio }}</template> + </MkInput> + <MkInput v-model="ad.expiresAt" type="date"> + <template #label>{{ $ts.expiration }}</template> + </MkInput> + <MkTextarea v-model="ad.memo"> + <template #label>{{ $ts.memo }}</template> + </MkTextarea> + <div class="buttons"> + <MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + </div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkRadio from '@/components/form/radio.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkTextarea, + MkRadio, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.ads, + icon: 'fas fa-audio-description', + bg: 'var(--bg)', + actions: [{ + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.add, + handler: this.add, + }], + }, + ads: [], + } + }, + + created() { + os.api('admin/ad/list').then(ads => { + this.ads = ads; + }); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + add() { + this.ads.unshift({ + id: null, + memo: '', + place: 'square', + priority: 'middle', + ratio: 1, + url: '', + imageUrl: null, + expiresAt: null, + }); + }, + + remove(ad) { + os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: ad.url }), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + this.ads = this.ads.filter(x => x != ad); + os.apiWithDialog('admin/ad/delete', { + id: ad.id + }); + }); + }, + + save(ad) { + if (ad.id == null) { + os.apiWithDialog('admin/ad/create', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime() + }); + } else { + os.apiWithDialog('admin/ad/update', { + ...ad, + expiresAt: new Date(ad.expiresAt).getTime() + }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.uqshojas { + margin: var(--margin); +} +</style> diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue new file mode 100644 index 0000000000..a64008967f --- /dev/null +++ b/packages/client/src/pages/admin/announcements.vue @@ -0,0 +1,125 @@ +<template> +<div class="ztgjmzrw"> + <section class="_card _gap announcements" v-for="announcement in announcements"> + <div class="_content announcement"> + <MkInput v-model="announcement.title"> + <template #label>{{ $ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text"> + <template #label>{{ $ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl"> + <template #label>{{ $ts.imageUrl }}</template> + </MkInput> + <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + </div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkTextarea, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.announcements, + icon: 'fas fa-broadcast-tower', + bg: 'var(--bg)', + actions: [{ + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.add, + handler: this.add, + }], + }, + announcements: [], + } + }, + + created() { + os.api('admin/announcements/list').then(announcements => { + this.announcements = announcements; + }); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + add() { + this.announcements.unshift({ + id: null, + title: '', + text: '', + imageUrl: null + }); + }, + + remove(announcement) { + os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: announcement.title }), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + this.announcements = this.announcements.filter(x => x != announcement); + os.api('admin/announcements/delete', announcement); + }); + }, + + save(announcement) { + if (announcement.id == null) { + os.api('admin/announcements/create', announcement).then(() => { + os.dialog({ + type: 'success', + text: this.$ts.saved + }); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + } else { + os.api('admin/announcements/update', announcement).then(() => { + os.dialog({ + type: 'success', + text: this.$ts.saved + }); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.ztgjmzrw { + margin: var(--margin); +} +</style> diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue new file mode 100644 index 0000000000..8f7873baa3 --- /dev/null +++ b/packages/client/src/pages/admin/bot-protection.vue @@ -0,0 +1,138 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormRadios v-model="provider"> + <template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template> + <option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option> + <option value="hcaptcha">hCaptcha</option> + <option value="recaptcha">reCAPTCHA</option> + </FormRadios> + + <template v-if="provider === 'hcaptcha'"> + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">hCaptcha</div> + <div class="main"> + <FormInput v-model="hcaptchaSiteKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.hcaptchaSiteKey }}</span> + </FormInput> + <FormInput v-model="hcaptchaSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.hcaptchaSecretKey }}</span> + </FormInput> + </div> + </div> + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">{{ $ts.preview }}</div> + <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);"> + <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/> + </div> + </div> + </template> + <template v-else-if="provider === 'recaptcha'"> + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">reCAPTCHA</div> + <div class="main"> + <FormInput v-model="recaptchaSiteKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.recaptchaSiteKey }}</span> + </FormInput> + <FormInput v-model="recaptchaSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>{{ $ts.recaptchaSecretKey }}</span> + </FormInput> + </div> + </div> + <div v-if="recaptchaSiteKey" class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">{{ $ts.preview }}</div> + <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);"> + <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/> + </div> + </div> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormRadios, + FormInput, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + MkCaptcha: defineAsyncComponent(() => import('@/components/captcha.vue')), + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.botProtection, + icon: 'fas fa-shield-alt' + }, + provider: null, + enableHcaptcha: false, + hcaptchaSiteKey: null, + hcaptchaSecretKey: null, + enableRecaptcha: false, + recaptchaSiteKey: null, + recaptchaSecretKey: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableHcaptcha = meta.enableHcaptcha; + this.hcaptchaSiteKey = meta.hcaptchaSiteKey; + this.hcaptchaSecretKey = meta.hcaptchaSecretKey; + this.enableRecaptcha = meta.enableRecaptcha; + this.recaptchaSiteKey = meta.recaptchaSiteKey; + this.recaptchaSecretKey = meta.recaptchaSecretKey; + + this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null; + + this.$watch(() => this.provider, () => { + this.enableHcaptcha = this.provider === 'hcaptcha'; + this.enableRecaptcha = this.provider === 'recaptcha'; + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + enableHcaptcha: this.enableHcaptcha, + hcaptchaSiteKey: this.hcaptchaSiteKey, + hcaptchaSecretKey: this.hcaptchaSecretKey, + enableRecaptcha: this.enableRecaptcha, + recaptchaSiteKey: this.recaptchaSiteKey, + recaptchaSecretKey: this.recaptchaSecretKey, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue new file mode 100644 index 0000000000..b550831e02 --- /dev/null +++ b/packages/client/src/pages/admin/database.vue @@ -0,0 +1,61 @@ +<template> +<FormBase> + <FormSuspense :p="databasePromiseFactory" v-slot="{ result: database }"> + <FormGroup v-for="table in database" :key="table[0]"> + <template #label>{{ table[0] }}</template> + <FormKeyValueView> + <template #key>Size</template> + <template #value>{{ bytes(table[1].size) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Records</template> + <template #value>{{ number(table[1].count) }}</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + FormSuspense, + FormKeyValueView, + FormBase, + FormGroup, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.database, + icon: 'fas fa-database', + bg: 'var(--bg)', + }, + databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + bytes, number, + } +}); +</script> diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue new file mode 100644 index 0000000000..3733f53a23 --- /dev/null +++ b/packages/client/src/pages/admin/email-settings.vue @@ -0,0 +1,128 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch> + + <template v-if="enableEmail"> + <FormInput v-model="email" type="email"> + <span>{{ $ts.emailAddress }}</span> + </FormInput> + + <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container> + <div class="_debobigegoLabel">{{ $ts.smtpConfig }}</div> + <div class="main"> + <FormInput v-model="smtpHost"> + <span>{{ $ts.smtpHost }}</span> + </FormInput> + <FormInput v-model="smtpPort" type="number"> + <span>{{ $ts.smtpPort }}</span> + </FormInput> + <FormInput v-model="smtpUser"> + <span>{{ $ts.smtpUser }}</span> + </FormInput> + <FormInput v-model="smtpPass" type="password"> + <span>{{ $ts.smtpPass }}</span> + </FormInput> + <FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo> + <FormSwitch v-model="smtpSecure">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></FormSwitch> + </div> + </div> + + <FormButton @click="testEmail">{{ $ts.testEmail }}</FormButton> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailServer, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + enableEmail: false, + email: null, + smtpSecure: false, + smtpHost: '', + smtpPort: 0, + smtpUser: '', + smtpPass: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableEmail = meta.enableEmail; + this.email = meta.email; + this.smtpSecure = meta.smtpSecure; + this.smtpHost = meta.smtpHost; + this.smtpPort = meta.smtpPort; + this.smtpUser = meta.smtpUser; + this.smtpPass = meta.smtpPass; + }, + + async testEmail() { + const { canceled, result: destination } = await os.dialog({ + title: this.$ts.destination, + input: { + placeholder: this.$instance.maintainerEmail + } + }); + if (canceled) return; + os.apiWithDialog('admin/send-email', { + to: destination, + subject: 'Test email', + text: 'Yo' + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + enableEmail: this.enableEmail, + email: this.email, + smtpSecure: this.smtpSecure, + smtpHost: this.smtpHost, + smtpPort: this.smtpPort, + smtpUser: this.smtpUser, + smtpPass: this.smtpPass, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/emoji-edit-dialog.vue b/packages/client/src/pages/admin/emoji-edit-dialog.vue new file mode 100644 index 0000000000..e612855105 --- /dev/null +++ b/packages/client/src/pages/admin/emoji-edit-dialog.vue @@ -0,0 +1,120 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + :with-ok-button="true" + @close="$refs.dialog.close()" + @closed="$emit('closed')" + @ok="ok()" +> + <template #header>:{{ emoji.name }}:</template> + + <div class="_monolithic_"> + <div class="yigymqpb _section"> + <img :src="emoji.url" class="img"/> + <MkInput class="_formBlock" v-model="name"> + <template #label>{{ $ts.name }}</template> + </MkInput> + <MkInput class="_formBlock" v-model="category" :datalist="categories"> + <template #label>{{ $ts.category }}</template> + </MkInput> + <MkInput class="_formBlock" v-model="aliases"> + <template #label>{{ $ts.tags }}</template> + <template #caption>{{ $ts.setMultipleBySeparatingWithSpace }}</template> + </MkInput> + <MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; +import { unique } from '@/scripts/array'; + +export default defineComponent({ + components: { + XModalWindow, + MkButton, + MkInput, + }, + + props: { + emoji: { + required: true, + } + }, + + emits: ['done', 'closed'], + + data() { + return { + name: this.emoji.name, + category: this.emoji.category, + aliases: this.emoji.aliases?.join(' '), + categories: [], + } + }, + + created() { + os.api('meta', { detail: false }).then(({ emojis }) => { + this.categories = unique(emojis.map((x: any) => x.category || '').filter((x: string) => x !== '')); + }); + }, + + methods: { + ok() { + this.update(); + }, + + async update() { + await os.apiWithDialog('admin/emoji/update', { + id: this.emoji.id, + name: this.name, + category: this.category, + aliases: this.aliases.split(' '), + }); + + this.$emit('done', { + updated: { + name: this.name, + category: this.category, + aliases: this.aliases.split(' '), + } + }); + this.$refs.dialog.close(); + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.emoji.name }), + showCancelButton: true + }); + if (canceled) return; + + os.api('admin/emoji/remove', { + id: this.emoji.id + }).then(() => { + this.$emit('done', { + deleted: true + }); + this.$refs.dialog.close(); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.yigymqpb { + > .img { + display: block; + height: 64px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue new file mode 100644 index 0000000000..c9ba193dd1 --- /dev/null +++ b/packages/client/src/pages/admin/emojis.vue @@ -0,0 +1,263 @@ +<template> +<div class="ogwlenmc"> + <div class="local" v-if="tab === 'local'"> + <MkInput v-model="query" :debounce="true" type="search" style="margin: var(--margin);"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.search }}</template> + </MkInput> + <MkPagination :pagination="pagination" ref="emojis"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> + </div> + + <div class="remote" v-else-if="tab === 'remote'"> + <MkInput v-model="queryRemote" :debounce="true" type="search" style="margin: var(--margin);"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.search }}</template> + </MkInput> + <MkInput v-model="host" :debounce="true" style="margin: var(--margin);"> + <template #label>{{ $ts.host }}</template> + </MkInput> + <MkPagination :pagination="remotePagination" ref="remoteEmojis"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> + </div> + </div> + </div> + </template> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, toRef } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkTab, + MkButton, + MkInput, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.customEmojis, + icon: 'fas fa-laugh', + bg: 'var(--bg)', + actions: [{ + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.addEmoji, + handler: this.add, + }], + tabs: [{ + active: this.tab === 'local', + title: this.$ts.local, + onClick: () => { this.tab = 'local'; }, + }, { + active: this.tab === 'remote', + title: this.$ts.remote, + onClick: () => { this.tab = 'remote'; }, + },] + })), + tab: 'local', + query: null, + queryRemote: null, + host: '', + pagination: { + endpoint: 'admin/emoji/list', + limit: 30, + params: computed(() => ({ + query: (this.query && this.query !== '') ? this.query : null + })) + }, + remotePagination: { + endpoint: 'admin/emoji/list-remote', + limit: 30, + params: computed(() => ({ + query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null, + host: (this.host && this.host !== '') ? this.host : null + })) + }, + } + }, + + async mounted() { + this.$emit('info', toRef(this, symbols.PAGE_INFO)); + }, + + methods: { + async add(e) { + const files = await selectFile(e.currentTarget || e.target, null, true); + + const promise = Promise.all(files.map(file => os.api('admin/emoji/add', { + fileId: file.id, + }))); + promise.then(() => { + this.$refs.emojis.reload(); + }); + os.promiseDialog(promise); + }, + + edit(emoji) { + os.popup(import('./emoji-edit-dialog.vue'), { + emoji: emoji + }, { + done: result => { + if (result.updated) { + this.$refs.emojis.replaceItem(item => item.id === emoji.id, { + ...emoji, + ...result.updated + }); + } else if (result.deleted) { + this.$refs.emojis.removeItem(item => item.id === emoji.id); + } + }, + }, 'closed'); + }, + + im(emoji) { + os.apiWithDialog('admin/emoji/copy', { + emojiId: emoji.id, + }); + }, + + remoteMenu(emoji, ev) { + os.popupMenu([{ + type: 'label', + text: ':' + emoji.name + ':', + }, { + text: this.$ts.import, + icon: 'fas fa-plus', + action: () => { this.im(emoji) } + }], ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.ogwlenmc { + > .local { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + + &:hover { + color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } + + > .remote { + .empty { + margin: var(--margin); + } + + .ldhfsamy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .emoji { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + + &:hover { + color: var(--accent); + } + + > .img { + width: 32px; + height: 32px; + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + font-size: 90%; + text-overflow: ellipsis; + overflow: hidden; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue new file mode 100644 index 0000000000..016a012ea5 --- /dev/null +++ b/packages/client/src/pages/admin/file-dialog.vue @@ -0,0 +1,129 @@ +<template> +<XModalWindow ref="dialog" + :width="370" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header v-if="file">{{ file.name }}</template> + <div class="cxqhhsmd" v-if="file"> + <div class="_section"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div class="info"> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + <MkTime :time="file.createdAt" mode="detail" style="display: block;"/> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkSwitch @update:modelValue="toggleIsSensitive" v-model="isSensitive">NSFW</MkSwitch> + </div> + </div> + <div class="_section"> + <div class="_content"> + <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton> + <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + </div> + </div> + <div class="_section" v-if="info"> + <details class="_content rawdata"> + <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> + </details> + </div> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import Progress from '@/scripts/loading'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + XModalWindow, + MkDriveFileThumbnail, + }, + + props: { + fileId: { + required: true, + } + }, + + emits: ['closed'], + + data() { + return { + file: null, + info: null, + isSensitive: false, + }; + }, + + created() { + this.fetch(); + }, + + methods: { + async fetch() { + Progress.start(); + this.file = await os.api('drive/files/show', { fileId: this.fileId }); + this.info = await os.api('admin/drive/show-file', { fileId: this.fileId }); + this.isSensitive = this.file.isSensitive; + Progress.done(); + }, + + showUser() { + os.pageWindow(`/user-info/${this.file.userId}`); + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.file.name }), + showCancelButton: true + }); + if (canceled) return; + + os.apiWithDialog('drive/files/delete', { + fileId: this.file.id + }); + }, + + async toggleIsSensitive(v) { + await os.api('drive/files/update', { fileId: this.fileId, isSensitive: v }); + this.isSensitive = v; + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> +.cxqhhsmd { + > ._section { + > .thumbnail { + height: 150px; + max-width: 100%; + } + + > .info { + text-align: center; + margin-top: 8px; + } + + > .rawdata { + overflow: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/admin/files-settings.vue b/packages/client/src/pages/admin/files-settings.vue new file mode 100644 index 0000000000..03d8f3de1f --- /dev/null +++ b/packages/client/src/pages/admin/files-settings.vue @@ -0,0 +1,93 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="cacheRemoteFiles"> + {{ $ts.cacheRemoteFiles }} + <template #desc>{{ $ts.cacheRemoteFilesDescription }}</template> + </FormSwitch> + + <FormSwitch v-model="proxyRemoteFiles"> + {{ $ts.proxyRemoteFiles }} + <template #desc>{{ $ts.proxyRemoteFilesDescription }}</template> + </FormSwitch> + + <FormInput v-model="localDriveCapacityMb" type="number"> + <span>{{ $ts.driveCapacityPerLocalAccount }}</span> + <template #suffix>MB</template> + <template #desc>{{ $ts.inMb }}</template> + </FormInput> + + <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles"> + <span>{{ $ts.driveCapacityPerRemoteAccount }}</span> + <template #suffix>MB</template> + <template #desc>{{ $ts.inMb }}</template> + </FormInput> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.files, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + }, + cacheRemoteFiles: false, + proxyRemoteFiles: false, + localDriveCapacityMb: 0, + remoteDriveCapacityMb: 0, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.cacheRemoteFiles = meta.cacheRemoteFiles; + this.proxyRemoteFiles = meta.proxyRemoteFiles; + this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; + this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; + }, + save() { + os.apiWithDialog('admin/update-meta', { + cacheRemoteFiles: this.cacheRemoteFiles, + proxyRemoteFiles: this.proxyRemoteFiles, + localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10), + remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10), + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue new file mode 100644 index 0000000000..e291d97bbc --- /dev/null +++ b/packages/client/src/pages/admin/files.vue @@ -0,0 +1,209 @@ +<template> +<div class="xrmjdkdw"> + <MkContainer :foldable="true" class="lookup"> + <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template> + <div class="xrmjdkdw-lookup"> + <MkInput class="item" v-model="q" type="text" @enter="find()"> + <template #label>{{ $ts.fileIdOrUrl }}</template> + </MkInput> + <MkButton @click="find()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton> + </div> + </MkContainer> + + <div class="_section"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.instance }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'"> + <template #label>{{ $ts.host }}</template> + </MkInput> + </div> + <div class="inputs" style="display: flex; padding-top: 1.2em;"> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files"> + <button class="file _panel _button _gap" v-for="file in items" :key="file.id" @click="show(file, $event)"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div class="body"> + <div> + <small style="opacity: 0.7;">{{ file.name }}</small> + </div> + <div> + <MkAcct v-if="file.user" :user="file.user"/> + <div v-else>{{ $ts.system }}</div> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + </div> + </button> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + MkContainer, + MkDriveFileThumbnail, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.files, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + actions: [{ + text: this.$ts.clearCachedFiles, + icon: 'fas fa-trash-alt', + handler: this.clear + }] + }, + q: null, + origin: 'local', + type: null, + searchHost: '', + pagination: { + endpoint: 'admin/drive/files', + limit: 10, + params: () => ({ + type: (this.type && this.type !== '') ? this.type : null, + origin: this.origin, + hostname: (this.hostname && this.hostname !== '') ? this.hostname : null, + }), + }, + } + }, + + watch: { + type() { + this.$refs.files.reload(); + }, + origin() { + this.$refs.files.reload(); + }, + searchHost() { + this.$refs.files.reload(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + clear() { + os.dialog({ + type: 'warning', + text: this.$ts.clearCachedFilesConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/drive/clean-remote-files', {}); + }); + }, + + show(file, ev) { + os.popup(import('./file-dialog.vue'), { + fileId: file.id + }, {}, 'closed'); + }, + + find() { + os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => { + this.show(file); + }).catch(e => { + if (e.code === 'NO_SUCH_FILE') { + os.dialog({ + type: 'error', + text: this.$ts.notFound + }); + } + }); + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> +.xrmjdkdw { + margin: var(--margin); + + > .lookup { + margin-bottom: 16px; + } + + .urempief { + margin-top: var(--margin); + + > .file { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + &:hover { + color: var(--accent); + } + + > .thumbnail { + width: 128px; + height: 128px; + } + + > .body { + margin-left: 0.3em; + padding: 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + } + } + } +} + +.xrmjdkdw-lookup { + padding: 16px; + + > .item { + margin-bottom: 16px; + } +} +</style> diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue new file mode 100644 index 0000000000..d3f9406db7 --- /dev/null +++ b/packages/client/src/pages/admin/index.vue @@ -0,0 +1,388 @@ +<template> +<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || page == null"> + <MkHeader :info="header"></MkHeader> + + <MkSpacer :content-max="700"> + <div class="lxpfedzu"> + <div class="banner"> + <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> + </div> + + <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo> + + <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div class="main"> + <MkStickyContainer> + <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template> + <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/> + </MkStickyContainer> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent, isRef, nextTick, onMounted, reactive, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkSuperMenu from '@/components/ui/super-menu.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import MkInfo from '@/components/ui/info.vue'; +import { scroll } from '@/scripts/scroll'; +import { instance } from '@/instance'; +import * as symbols from '@/symbols'; +import * as os from '@/os'; +import { lookupUser } from '@/scripts/lookup-user'; + +export default defineComponent({ + components: { + FormBase, + MkSuperMenu, + FormGroup, + FormButton, + MkInfo, + }, + + provide: { + shouldOmitHeaderTitle: false, + }, + + props: { + initialPage: { + type: String, + required: false + } + }, + + setup(props, context) { + const indexInfo = { + title: i18n.locale.controlPanel, + icon: 'fas fa-cog', + bg: 'var(--bg)', + hideHeader: true, + }; + const INFO = ref(indexInfo); + const childInfo = ref(null); + const page = ref(props.initialPage); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const onInfo = (viewInfo) => { + if (isRef(viewInfo)) { + watch(viewInfo, () => { + childInfo.value = viewInfo.value; + }, { immediate: true }); + } else { + childInfo.value = viewInfo; + } + }; + const pageProps = ref({}); + + const isEmpty = (x: any) => x == null || x == ''; + + const noMaintainerInformation = ref(false); + const noBotProtection = ref(false); + + os.api('meta', { detail: true }).then(meta => { + // TODO: 設定が完了しても残ったままになるので、ストリーミングでmeta更新イベントを受け取ってよしなに更新する + noMaintainerInformation.value = isEmpty(meta.maintainerName) || isEmpty(meta.maintainerEmail); + noBotProtection.value = !meta.enableHcaptcha && !meta.enableRecaptcha; + }); + + const menuDef = computed(() => [{ + title: i18n.locale.quickAction, + items: [{ + type: 'button', + icon: 'fas fa-search', + text: i18n.locale.lookup, + action: lookup, + }, ...(instance.disableRegistration ? [{ + type: 'button', + icon: 'fas fa-user', + text: i18n.locale.invite, + action: invite, + }] : [])], + }, { + title: i18n.locale.administration, + items: [{ + icon: 'fas fa-tachometer-alt', + text: i18n.locale.dashboard, + to: '/admin/overview', + active: page.value === 'overview', + }, { + icon: 'fas fa-users', + text: i18n.locale.users, + to: '/admin/users', + active: page.value === 'users', + }, { + icon: 'fas fa-laugh', + text: i18n.locale.customEmojis, + to: '/admin/emojis', + active: page.value === 'emojis', + }, { + icon: 'fas fa-globe', + text: i18n.locale.federation, + to: '/admin/federation', + active: page.value === 'federation', + }, { + icon: 'fas fa-clipboard-list', + text: i18n.locale.jobQueue, + to: '/admin/queue', + active: page.value === 'queue', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.files, + to: '/admin/files', + active: page.value === 'files', + }, { + icon: 'fas fa-broadcast-tower', + text: i18n.locale.announcements, + to: '/admin/announcements', + active: page.value === 'announcements', + }, { + icon: 'fas fa-audio-description', + text: i18n.locale.ads, + to: '/admin/ads', + active: page.value === 'ads', + }, { + icon: 'fas fa-exclamation-circle', + text: i18n.locale.abuseReports, + to: '/admin/abuses', + active: page.value === 'abuses', + }], + }, { + title: i18n.locale.settings, + items: [{ + icon: 'fas fa-cog', + text: i18n.locale.general, + to: '/admin/settings', + active: page.value === 'settings', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.files, + to: '/admin/files-settings', + active: page.value === 'files-settings', + }, { + icon: 'fas fa-envelope', + text: i18n.locale.emailServer, + to: '/admin/email-settings', + active: page.value === 'email-settings', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.objectStorage, + to: '/admin/object-storage', + active: page.value === 'object-storage', + }, { + icon: 'fas fa-lock', + text: i18n.locale.security, + to: '/admin/security', + active: page.value === 'security', + }, { + icon: 'fas fa-bolt', + text: 'ServiceWorker', + to: '/admin/service-worker', + active: page.value === 'service-worker', + }, { + icon: 'fas fa-globe', + text: i18n.locale.relays, + to: '/admin/relays', + active: page.value === 'relays', + }, { + icon: 'fas fa-share-alt', + text: i18n.locale.integration, + to: '/admin/integrations', + active: page.value === 'integrations', + }, { + icon: 'fas fa-ban', + text: i18n.locale.instanceBlocking, + to: '/admin/instance-block', + active: page.value === 'instance-block', + }, { + icon: 'fas fa-ghost', + text: i18n.locale.proxyAccount, + to: '/admin/proxy-account', + active: page.value === 'proxy-account', + }, { + icon: 'fas fa-cogs', + text: i18n.locale.other, + to: '/admin/other-settings', + active: page.value === 'other-settings', + }], + }, { + title: i18n.locale.info, + items: [{ + icon: 'fas fa-database', + text: i18n.locale.database, + to: '/admin/database', + active: page.value === 'database', + }], + }]); + const component = computed(() => { + if (page.value == null) return null; + switch (page.value) { + case 'overview': return defineAsyncComponent(() => import('./overview.vue')); + case 'users': return defineAsyncComponent(() => import('./users.vue')); + case 'emojis': return defineAsyncComponent(() => import('./emojis.vue')); + case 'federation': return defineAsyncComponent(() => import('../federation.vue')); + case 'queue': return defineAsyncComponent(() => import('./queue.vue')); + case 'files': return defineAsyncComponent(() => import('./files.vue')); + case 'announcements': return defineAsyncComponent(() => import('./announcements.vue')); + case 'ads': return defineAsyncComponent(() => import('./ads.vue')); + case 'database': return defineAsyncComponent(() => import('./database.vue')); + case 'abuses': return defineAsyncComponent(() => import('./abuses.vue')); + case 'settings': return defineAsyncComponent(() => import('./settings.vue')); + case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue')); + case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue')); + case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue')); + case 'security': return defineAsyncComponent(() => import('./security.vue')); + case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue')); + case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue')); + case 'relays': return defineAsyncComponent(() => import('./relays.vue')); + case 'integrations': return defineAsyncComponent(() => import('./integrations.vue')); + case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue')); + case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue')); + case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue')); + case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue')); + case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue')); + case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue')); + } + }); + + watch(component, () => { + pageProps.value = {}; + + nextTick(() => { + scroll(el.value, { top: 0 }); + }); + }, { immediate: true }); + + watch(() => props.initialPage, () => { + if (props.initialPage == null && !narrow.value) { + page.value = 'overview'; + } else { + page.value = props.initialPage; + if (props.initialPage == null) { + INFO.value = indexInfo; + } + } + }); + + onMounted(() => { + narrow.value = el.value.offsetWidth < 800; + if (!narrow.value) { + page.value = 'overview'; + } + }); + + const invite = () => { + os.api('admin/invite').then(x => { + os.dialog({ + type: 'info', + text: x.code + }); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + }; + + const lookup = (ev) => { + os.popupMenu([{ + text: i18n.locale.user, + icon: 'fas fa-user', + action: () => { + lookupUser(); + } + }, { + text: i18n.locale.note, + icon: 'fas fa-pencil-alt', + action: () => { + alert('TODO'); + } + }, { + text: i18n.locale.file, + icon: 'fas fa-cloud', + action: () => { + alert('TODO'); + } + }, { + text: i18n.locale.instance, + icon: 'fas fa-globe', + action: () => { + alert('TODO'); + } + }], ev.currentTarget || ev.target); + }; + + return { + [symbols.PAGE_INFO]: INFO, + menuDef, + header: { + title: i18n.locale.controlPanel, + }, + noMaintainerInformation, + noBotProtection, + page, + narrow, + view, + el, + onInfo, + childInfo, + pageProps, + component, + invite, + lookup, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.hiyeyicy { + &.wide { + display: flex; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + max-width: 280px; + box-sizing: border-box; + border-right: solid 0.5px var(--divider); + overflow: auto; + height: 100%; + } + + > .main { + flex: 1; + min-width: 0; + } + } + + > .nav { + .lxpfedzu { + > .info { + margin: 16px 0; + } + + > .banner { + margin: 16px; + + > .icon { + display: block; + margin: auto; + height: 42px; + border-radius: 8px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue new file mode 100644 index 0000000000..f5b249698d --- /dev/null +++ b/packages/client/src/pages/admin/instance-block.vue @@ -0,0 +1,72 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormTextarea v-model="blockedHosts"> + <span>{{ $ts.blockedInstances }}</span> + <template #desc>{{ $ts.blockedInstancesDescription }}</template> + </FormTextarea> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceBlocking, + icon: 'fas fa-ban', + bg: 'var(--bg)', + }, + blockedHosts: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.blockedHosts = meta.blockedHosts.join('\n'); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + blockedHosts: this.blockedHosts.split('\n') || [], + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/instance.vue b/packages/client/src/pages/admin/instance.vue new file mode 100644 index 0000000000..614eaa3048 --- /dev/null +++ b/packages/client/src/pages/admin/instance.vue @@ -0,0 +1,321 @@ +<template> +<XModalWindow ref="dialog" + :width="520" + :height="500" + @close="$refs.dialog.close()" + @closed="$emit('closed')" +> + <template #header>{{ instance.host }}</template> + <div class="mk-instance-info"> + <div class="_table section"> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.software }}</div> + <div class="_data">{{ instance.softwareName || '?' }}</div> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.version }}</div> + <div class="_data">{{ instance.softwareVersion || '?' }}</div> + </div> + </div> + </div> + <div class="_table data section"> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.registeredAt }}</div> + <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<MkTime :time="instance.caughtAt"/>)</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.following }}</div> + <button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.followers }}</div> + <button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.users }}</div> + <button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.notes }}</div> + <div class="_data">{{ number(instance.notesCount) }}</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.files }}</div> + <div class="_data">{{ number(instance.driveFiles) }}</div> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.storageUsage }}</div> + <div class="_data">{{ bytes(instance.driveUsage) }}</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.latestRequestSentAt }}</div> + <div class="_data"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> + </div> + <div class="_cell"> + <div class="_label">{{ $ts.latestStatus }}</div> + <div class="_data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div> + </div> + </div> + <div class="_row"> + <div class="_cell"> + <div class="_label">{{ $ts.latestRequestReceivedAt }}</div> + <div class="_data"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> + </div> + </div> + </div> + <div class="chart"> + <div class="header"> + <span class="label">{{ $ts.charts }}</span> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ $ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart> + </div> + </div> + <div class="operations section"> + <span class="label">{{ $ts.operations }}</span> + <MkSwitch v-model="isSuspended" class="switch">{{ $ts.stopActivityDelivery }}</MkSwitch> + <MkSwitch :model-value="isBlocked" class="switch" @update:modelValue="changeBlock">{{ $ts.blockThisInstance }}</MkSwitch> + <details> + <summary>{{ $ts.deleteAllFiles }}</summary> + <MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton> + </details> + <details> + <summary>{{ $ts.removeAllFollowing }}</summary> + <MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-minus-circle"></i> {{ $ts.removeAllFollowing }}</MkButton> + <MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo> + </details> + </div> + <details class="metadata section"> + <summary class="label">{{ $ts.metadata }}</summary> + <pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre> + </details> + </div> +</XModalWindow> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; +import MkUsersDialog from '@/components/users-dialog.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInfo from '@/components/ui/info.vue'; +import MkChart from '@/components/chart.vue'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XModalWindow, + MkSelect, + MkButton, + MkSwitch, + MkInfo, + MkChart, + }, + + props: { + instance: { + type: Object, + required: true + } + }, + + emits: ['closed'], + + data() { + return { + isSuspended: this.instance.isSuspended, + chartSrc: 'requests', + chartSpan: 'hour', + }; + }, + + computed: { + meta() { + return this.$instance; + }, + + isBlocked() { + return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host); + } + }, + + watch: { + isSuspended() { + os.api('admin/federation/update-instance', { + host: this.instance.host, + isSuspended: this.isSuspended + }); + }, + }, + + methods: { + changeBlock(e) { + os.api('admin/update-meta', { + blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host) + }); + }, + + removeAllFollowing() { + os.apiWithDialog('admin/federation/remove-all-following', { + host: this.instance.host + }); + }, + + deleteAllFiles() { + os.apiWithDialog('admin/federation/delete-all-files', { + host: this.instance.host + }); + }, + + showFollowing() { + os.modal(MkUsersDialog, { + title: this.$ts.instanceFollowing, + pagination: { + endpoint: 'federation/following', + limit: 10, + params: { + host: this.instance.host + } + }, + extract: item => item.follower + }); + }, + + showFollowers() { + os.modal(MkUsersDialog, { + title: this.$ts.instanceFollowers, + pagination: { + endpoint: 'federation/followers', + limit: 10, + params: { + host: this.instance.host + } + }, + extract: item => item.followee + }); + }, + + showUsers() { + os.modal(MkUsersDialog, { + title: this.$ts.instanceUsers, + pagination: { + endpoint: 'federation/users', + limit: 10, + params: { + host: this.instance.host + } + } + }); + }, + + bytes, + + number + } +}); +</script> + +<style lang="scss" scoped> +.mk-instance-info { + overflow: auto; + + > .section { + padding: 16px 32px; + + @media (max-width: 500px) { + padding: 8px 16px; + } + + &:not(:first-child) { + border-top: solid 0.5px var(--divider); + } + } + + > .chart { + border-top: solid 0.5px var(--divider); + padding: 16px 0 12px 0; + + > .header { + padding: 0 32px; + + @media (max-width: 500px) { + padding: 0 16px; + } + + > .label { + font-size: 80%; + opacity: 0.7; + } + + > .selects { + display: flex; + } + } + + > .chart { + padding: 0 16px; + + @media (max-width: 500px) { + padding: 0; + } + } + } + + > .operations { + > .label { + font-size: 80%; + opacity: 0.7; + } + + > .switch { + margin: 16px 0; + } + } + + > .metadata { + > .label { + font-size: 80%; + opacity: 0.7; + } + + > pre > code { + display: block; + max-height: 200px; + overflow: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/admin/integrations-discord.vue b/packages/client/src/pages/admin/integrations-discord.vue new file mode 100644 index 0000000000..81e47499c6 --- /dev/null +++ b/packages/client/src/pages/admin/integrations-discord.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableDiscordIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableDiscordIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo> + + <FormInput v-model="discordClientId"> + <template #prefix><i class="fas fa-key"></i></template> + Client ID + </FormInput> + + <FormInput v-model="discordClientSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Client Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Discord', + icon: 'fab fa-discord' + }, + enableDiscordIntegration: false, + discordClientId: null, + discordClientSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableDiscordIntegration = meta.enableDiscordIntegration; + this.discordClientId = meta.discordClientId; + this.discordClientSecret = meta.discordClientSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableDiscordIntegration: this.enableDiscordIntegration, + discordClientId: this.discordClientId, + discordClientSecret: this.discordClientSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/integrations-github.vue b/packages/client/src/pages/admin/integrations-github.vue new file mode 100644 index 0000000000..2bbc3ae9a1 --- /dev/null +++ b/packages/client/src/pages/admin/integrations-github.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableGithubIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableGithubIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo> + + <FormInput v-model="githubClientId"> + <template #prefix><i class="fas fa-key"></i></template> + Client ID + </FormInput> + + <FormInput v-model="githubClientSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Client Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'GitHub', + icon: 'fab fa-github' + }, + enableGithubIntegration: false, + githubClientId: null, + githubClientSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableGithubIntegration = meta.enableGithubIntegration; + this.githubClientId = meta.githubClientId; + this.githubClientSecret = meta.githubClientSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableGithubIntegration: this.enableGithubIntegration, + githubClientId: this.githubClientId, + githubClientSecret: this.githubClientSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/integrations-twitter.vue b/packages/client/src/pages/admin/integrations-twitter.vue new file mode 100644 index 0000000000..19ed216ab9 --- /dev/null +++ b/packages/client/src/pages/admin/integrations-twitter.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableTwitterIntegration"> + {{ $ts.enable }} + </FormSwitch> + + <template v-if="enableTwitterIntegration"> + <FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo> + + <FormInput v-model="twitterConsumerKey"> + <template #prefix><i class="fas fa-key"></i></template> + Consumer Key + </FormInput> + + <FormInput v-model="twitterConsumerSecret"> + <template #prefix><i class="fas fa-key"></i></template> + Consumer Secret + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormInfo, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Twitter', + icon: 'fab fa-twitter' + }, + enableTwitterIntegration: false, + twitterConsumerKey: null, + twitterConsumerSecret: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.twitterConsumerKey = meta.twitterConsumerKey; + this.twitterConsumerSecret = meta.twitterConsumerSecret; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableTwitterIntegration: this.enableTwitterIntegration, + twitterConsumerKey: this.twitterConsumerKey, + twitterConsumerSecret: this.twitterConsumerSecret, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue new file mode 100644 index 0000000000..c21eebc1c6 --- /dev/null +++ b/packages/client/src/pages/admin/integrations.vue @@ -0,0 +1,74 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormLink to="/admin/integrations/twitter"> + <i class="fab fa-twitter"></i> Twitter + <template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + <FormLink to="/admin/integrations/github"> + <i class="fab fa-github"></i> GitHub + <template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + <FormLink to="/admin/integrations/discord"> + <i class="fab fa-discord"></i> Discord + <template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template> + </FormLink> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormLink, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.integration, + icon: 'fas fa-share-alt', + bg: 'var(--bg)', + }, + enableTwitterIntegration: false, + enableGithubIntegration: false, + enableDiscordIntegration: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.enableGithubIntegration = meta.enableGithubIntegration; + this.enableDiscordIntegration = meta.enableDiscordIntegration; + }, + } +}); +</script> diff --git a/packages/client/src/pages/admin/metrics.vue b/packages/client/src/pages/admin/metrics.vue new file mode 100644 index 0000000000..05b64b235c --- /dev/null +++ b/packages/client/src/pages/admin/metrics.vue @@ -0,0 +1,472 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="cpumem"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div> + <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="disk"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div> + <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div> + </div> + </div> + </div> + </div> +</div> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div> + <div class="_debobigegoPanel xhexznfu"> + <div> + <canvas :ref="net"></canvas> + </div> + <div v-if="serverInfo"> + <div class="_table"> + <div class="_row"> + <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle +} from 'chart.js'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkwFederation from '../../widgets/federation.vue'; +import { version, url } from '@/config'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkInstanceInfo from './instance.vue'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + Legend, + Title, + Tooltip, + SubTitle +); + +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})`; +}; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + MkInput, + MkContainer, + MkFolder, + MkwFederation, + }, + + data() { + return { + version, + url, + stats: null, + serverInfo: null, + connection: null, + queueConnection: markRaw(os.stream.useChannel('queueStats')), + memUsage: 0, + chartCpuMem: null, + chartNet: null, + jobs: [], + logs: [], + logLevel: 'all', + logDomain: '', + modLogs: [], + dbInfo: null, + overviewHeight: '1fr', + queueHeight: '1fr', + paused: false, + } + }, + + computed: { + gridColor() { + // TODO: var(--panel)の色が暗いか明るいかで判定する + return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + }, + }, + + mounted() { + this.fetchJobs(); + + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + os.api('admin/server-info', {}).then(res => { + this.serverInfo = res; + + this.connection = markRaw(os.stream.useChannel('serverStats')); + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 150 + }); + + this.$nextTick(() => { + this.queueConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + } + this.queueConnection.dispose(); + }, + + methods: { + cpumem(el) { + if (this.chartCpuMem != null) return; + this.chartCpuMem = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'CPU', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#86b300', + backgroundColor: alpha('#86b300', 0.1), + data: [] + }, { + label: 'MEM (active)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + backgroundColor: alpha('#935dbf', 0.02), + data: [] + }, { + label: 'MEM (used)', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#935dbf', + borderDash: [5, 5], + fill: false, + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + max: 100 + } + } + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + net(el) { + if (this.chartNet != null) return; + this.chartNet = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'In', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Out', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + } + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + disk(el) { + if (this.chartDisk != null) return; + this.chartDisk = markRaw(new Chart(el, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Read', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#94a029', + backgroundColor: alpha('#94a029', 0.1), + data: [] + }, { + label: 'Write', + pointRadius: 0, + tension: 0, + borderWidth: 2, + borderColor: '#ff9156', + backgroundColor: alpha('#ff9156', 0.1), + data: [] + }] + }, + options: { + aspectRatio: 3, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 0 + } + }, + legend: { + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + scales: { + x: { + gridLines: { + display: false, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false + } + }, + y: { + position: 'right', + gridLines: { + display: true, + color: this.gridColor, + zeroLineColor: this.gridColor, + }, + ticks: { + display: false, + } + } + }, + tooltips: { + intersect: false, + mode: 'index', + } + } + })); + }, + + fetchJobs() { + os.api('admin/queue/deliver-delayed', {}).then(jobs => { + this.jobs = jobs; + }); + }, + + onStats(stats) { + if (this.paused) return; + + const cpu = (stats.cpu * 100).toFixed(0); + const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0); + const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0); + this.memUsage = stats.mem.active; + + this.chartCpuMem.data.labels.push(''); + this.chartCpuMem.data.datasets[0].data.push(cpu); + this.chartCpuMem.data.datasets[1].data.push(memActive); + this.chartCpuMem.data.datasets[2].data.push(memUsed); + this.chartNet.data.labels.push(''); + this.chartNet.data.datasets[0].data.push(stats.net.rx); + this.chartNet.data.datasets[1].data.push(stats.net.tx); + this.chartDisk.data.labels.push(''); + this.chartDisk.data.datasets[0].data.push(stats.fs.r); + this.chartDisk.data.datasets[1].data.push(stats.fs.w); + if (this.chartCpuMem.data.datasets[0].data.length > 150) { + this.chartCpuMem.data.labels.shift(); + this.chartCpuMem.data.datasets[0].data.shift(); + this.chartCpuMem.data.datasets[1].data.shift(); + this.chartCpuMem.data.datasets[2].data.shift(); + this.chartNet.data.labels.shift(); + this.chartNet.data.datasets[0].data.shift(); + this.chartNet.data.datasets[1].data.shift(); + this.chartDisk.data.labels.shift(); + this.chartDisk.data.datasets[0].data.shift(); + this.chartDisk.data.datasets[1].data.shift(); + } + this.chartCpuMem.update(); + this.chartNet.update(); + this.chartDisk.update(); + }, + + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + }, + + bytes, + + number, + + pause() { + this.paused = true; + }, + + resume() { + this.paused = false; + }, + } +}); +</script> + +<style lang="scss" scoped> +.xhexznfu { + > div:nth-child(2) { + padding: 16px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue new file mode 100644 index 0000000000..0f1431c258 --- /dev/null +++ b/packages/client/src/pages/admin/object-storage.vue @@ -0,0 +1,155 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch> + + <template v-if="useObjectStorage"> + <FormInput v-model="objectStorageBaseUrl"> + <span>{{ $ts.objectStorageBaseUrl }}</span> + <template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageBucket"> + <span>{{ $ts.objectStorageBucket }}</span> + <template #desc>{{ $ts.objectStorageBucketDesc }}</template> + </FormInput> + + <FormInput v-model="objectStoragePrefix"> + <span>{{ $ts.objectStoragePrefix }}</span> + <template #desc>{{ $ts.objectStoragePrefixDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageEndpoint"> + <span>{{ $ts.objectStorageEndpoint }}</span> + <template #desc>{{ $ts.objectStorageEndpointDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageRegion"> + <span>{{ $ts.objectStorageRegion }}</span> + <template #desc>{{ $ts.objectStorageRegionDesc }}</template> + </FormInput> + + <FormInput v-model="objectStorageAccessKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>Access key</span> + </FormInput> + + <FormInput v-model="objectStorageSecretKey"> + <template #prefix><i class="fas fa-key"></i></template> + <span>Secret key</span> + </FormInput> + + <FormSwitch v-model="objectStorageUseSSL"> + {{ $ts.objectStorageUseSSL }} + <template #desc>{{ $ts.objectStorageUseSSLDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageUseProxy"> + {{ $ts.objectStorageUseProxy }} + <template #desc>{{ $ts.objectStorageUseProxyDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageSetPublicRead"> + {{ $ts.objectStorageSetPublicRead }} + </FormSwitch> + + <FormSwitch v-model="objectStorageS3ForcePathStyle"> + s3ForcePathStyle + </FormSwitch> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.objectStorage, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + }, + useObjectStorage: false, + objectStorageBaseUrl: null, + objectStorageBucket: null, + objectStoragePrefix: null, + objectStorageEndpoint: null, + objectStorageRegion: null, + objectStoragePort: null, + objectStorageAccessKey: null, + objectStorageSecretKey: null, + objectStorageUseSSL: false, + objectStorageUseProxy: false, + objectStorageSetPublicRead: false, + objectStorageS3ForcePathStyle: true, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.useObjectStorage = meta.useObjectStorage; + this.objectStorageBaseUrl = meta.objectStorageBaseUrl; + this.objectStorageBucket = meta.objectStorageBucket; + this.objectStoragePrefix = meta.objectStoragePrefix; + this.objectStorageEndpoint = meta.objectStorageEndpoint; + this.objectStorageRegion = meta.objectStorageRegion; + this.objectStoragePort = meta.objectStoragePort; + this.objectStorageAccessKey = meta.objectStorageAccessKey; + this.objectStorageSecretKey = meta.objectStorageSecretKey; + this.objectStorageUseSSL = meta.objectStorageUseSSL; + this.objectStorageUseProxy = meta.objectStorageUseProxy; + this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead; + this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle; + }, + save() { + os.apiWithDialog('admin/update-meta', { + useObjectStorage: this.useObjectStorage, + objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null, + objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null, + objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null, + objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null, + objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null, + objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null, + objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null, + objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null, + objectStorageUseSSL: this.objectStorageUseSSL, + objectStorageUseProxy: this.objectStorageUseProxy, + objectStorageSetPublicRead: this.objectStorageSetPublicRead, + objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue new file mode 100644 index 0000000000..e8f872bf0a --- /dev/null +++ b/packages/client/src/pages/admin/other-settings.vue @@ -0,0 +1,83 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormGroup> + <FormInput v-model="summalyProxy"> + <template #prefix><i class="fas fa-link"></i></template> + Summaly Proxy URL + </FormInput> + </FormGroup> + <FormGroup> + <FormInput v-model="deeplAuthKey"> + <template #prefix><i class="fas fa-key"></i></template> + DeepL Auth Key + </FormInput> + <FormSwitch v-model="deeplIsPro"> + Pro account + </FormSwitch> + </FormGroup> + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.other, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + summalyProxy: '', + deeplAuthKey: '', + deeplIsPro: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.summalyProxy = meta.summalyProxy; + this.deeplAuthKey = meta.deeplAuthKey; + this.deeplIsPro = meta.deeplIsPro; + }, + save() { + os.apiWithDialog('admin/update-meta', { + summalyProxy: this.summalyProxy, + deeplAuthKey: this.deeplAuthKey, + deeplIsPro: this.deeplIsPro, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue new file mode 100644 index 0000000000..e1352945a1 --- /dev/null +++ b/packages/client/src/pages/admin/overview.vue @@ -0,0 +1,236 @@ +<template> +<div class="edbbcaef" v-size="{ max: [740] }"> + <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)"> + <div class="number _panel"> + <div class="label">Users</div> + <div class="value _monospace"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-if="usersComparedToThePrevDay != null" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + <div class="number _panel"> + <div class="label">Notes</div> + <div class="value _monospace"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-if="notesComparedToThePrevDay != null" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + </div> + + <MkContainer :foldable="true" class="charts"> + <template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template> + <div style="padding-top: 12px;"> + <MkInstanceStats :chart-limit="500" :detailed="true"/> + </div> + </MkContainer> + + <div class="queue"> + <MkContainer :foldable="true" :thin="true" class="deliver"> + <template #header>Queue: deliver</template> + <MkQueueChart :connection="queueStatsConnection" domain="deliver"/> + </MkContainer> + <MkContainer :foldable="true" :thin="true" class="inbox"> + <template #header>Queue: inbox</template> + <MkQueueChart :connection="queueStatsConnection" domain="inbox"/> + </MkContainer> + </div> + + <!--<XMetrics/>--> + + <MkFolder style="margin: var(--margin)"> + <template #header><i class="fas fa-info-circle"></i> {{ $ts.info }}</template> + <div class="cfcdecdf"> + <div class="number _panel"> + <div class="label">Misskey</div> + <div class="value _monospace">{{ version }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">Node.js</div> + <div class="value _monospace">{{ serverInfo.node }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">PostgreSQL</div> + <div class="value _monospace">{{ serverInfo.psql }}</div> + </div> + <div class="number _panel" v-if="serverInfo"> + <div class="label">Redis</div> + <div class="value _monospace">{{ serverInfo.redis }}</div> + </div> + <div class="number _panel"> + <div class="label">Vue</div> + <div class="value _monospace">{{ vueVersion }}</div> + </div> + </div> + </MkFolder> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw, version as vueVersion } from 'vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import MkInstanceStats from '@/components/instance-stats.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkNumberDiff from '@/components/number-diff.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkQueueChart from '@/components/queue-chart.vue'; +import { version, url } from '@/config'; +import bytes from '@/filters/bytes'; +import number from '@/filters/number'; +import MkInstanceInfo from './instance.vue'; +import XMetrics from './metrics.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkNumberDiff, + FormKeyValueView, + MkInstanceStats, + MkContainer, + MkFolder, + MkQueueChart, + XMetrics, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.dashboard, + icon: 'fas fa-tachometer-alt', + bg: 'var(--bg)', + }, + version, + vueVersion, + url, + stats: null, + meta: null, + serverInfo: null, + usersComparedToThePrevDay: null, + notesComparedToThePrevDay: null, + fetchJobs: () => os.api('admin/queue/deliver-delayed', {}), + fetchModLogs: () => os.api('admin/show-moderation-logs', {}), + queueStatsConnection: markRaw(os.stream.useChannel('queueStats')), + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats', {}).then(stats => { + this.stats = stats; + + os.api('charts/users', { limit: 2, span: 'day' }).then(chart => { + this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1]; + }); + + os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => { + this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1]; + }); + }); + + os.api('admin/server-info', {}).then(serverInfo => { + this.serverInfo = serverInfo; + }); + + this.$nextTick(() => { + this.queueStatsConnection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }, + + beforeUnmount() { + this.queueStatsConnection.dispose(); + }, + + methods: { + async showInstanceInfo(q) { + let instance = q; + if (typeof q === 'string') { + instance = await os.api('federation/show-instance', { + host: q + }); + } + os.popup(MkInstanceInfo, { + instance: instance + }, {}, 'closed'); + }, + + bytes, + + number, + } +}); +</script> + +<style lang="scss" scoped> +.edbbcaef { + .cfcdecdf { + display: grid; + grid-gap: 8px; + grid-template-columns: repeat(auto-fill,minmax(150px,1fr)); + + > .number { + padding: 12px 16px; + + > .label { + opacity: 0.7; + font-size: 0.8em; + } + + > .value { + font-weight: bold; + font-size: 1.2em; + + > .diff { + font-size: 0.8em; + } + } + } + } + + > .charts { + margin: var(--margin); + } + + > .queue { + margin: var(--margin); + display: flex; + + > .deliver, + > .inbox { + flex: 1; + width: 50%; + + &:not(:first-child) { + margin-left: var(--margin); + } + } + } + + &.max-width_740px { + > .queue { + display: block; + + > .deliver, + > .inbox { + width: 100%; + + &:not(:first-child) { + margin-top: var(--margin); + margin-left: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue new file mode 100644 index 0000000000..5852c6a20d --- /dev/null +++ b/packages/client/src/pages/admin/proxy-account.vue @@ -0,0 +1,87 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.proxyAccount }}</template> + <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : $ts.none }}</template> + </FormKeyValueView> + <template #caption>{{ $ts.proxyAccountDescription }}</template> + </FormGroup> + + <FormButton @click="chooseProxyAccount" primary>{{ $ts.selectAccount }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormKeyValueView, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.proxyAccount, + icon: 'fas fa-ghost', + bg: 'var(--bg)', + }, + proxyAccount: null, + proxyAccountId: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.proxyAccountId = meta.proxyAccountId; + if (this.proxyAccountId) { + this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId }); + } + }, + + chooseProxyAccount() { + os.selectUser().then(user => { + this.proxyAccount = user; + this.proxyAccountId = user.id; + this.save(); + }); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + proxyAccountId: this.proxyAccountId, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue new file mode 100644 index 0000000000..136fb63bb6 --- /dev/null +++ b/packages/client/src/pages/admin/queue.chart.vue @@ -0,0 +1,102 @@ +<template> +<div class="_debobigegoItem"> + <div class="_debobigegoLabel"><slot name="title"></slot></div> + <div class="_debobigegoPanel pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class=""> + <MkQueueChart :domain="domain" :connection="connection"/> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> + </div> + </div> + <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw, onMounted, onUnmounted, ref } from 'vue'; +import number from '@/filters/number'; +import MkQueueChart from '@/components/queue-chart.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkQueueChart + }, + + props: { + domain: { + type: String, + required: true, + }, + connection: { + required: true, + }, + }, + + setup(props) { + const activeSincePrevTick = ref(0); + const active = ref(0); + const waiting = ref(0); + const delayed = ref(0); + const jobs = ref([]); + + onMounted(() => { + os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { + jobs.value = result; + }); + + const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + waiting.value = stats[props.domain].waiting; + delayed.value = stats[props.domain].delayed; + }; + + props.connection.on('stats', onStats); + + onUnmounted(() => { + props.connection.off('stats', onStats); + }); + }); + + return { + jobs, + activeSincePrevTick, + active, + waiting, + delayed, + number, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.pumxzjhg { + > .status { + padding: 16px; + border-bottom: solid 0.5px var(--divider); + } + + > .jobs { + padding: 16px; + border-top: solid 0.5px var(--divider); + max-height: 180px; + overflow: auto; + } +} +</style> diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue new file mode 100644 index 0000000000..896298840c --- /dev/null +++ b/packages/client/src/pages/admin/queue.vue @@ -0,0 +1,73 @@ +<template> +<FormBase> + <XQueue :connection="connection" domain="inbox"> + <template #title>In</template> + </XQueue> + <XQueue :connection="connection" domain="deliver"> + <template #title>Out</template> + </XQueue> + <FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XQueue from './queue.chart.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + MkButton, + XQueue, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.jobQueue, + icon: 'fas fa-clipboard-list', + bg: 'var(--bg)', + }, + connection: markRaw(os.stream.useChannel('queueStats')), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + this.$nextTick(() => { + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200 + }); + }); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + clear() { + os.dialog({ + type: 'warning', + title: this.$ts.clearQueueConfirmTitle, + text: this.$ts.clearQueueConfirmText, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + os.apiWithDialog('admin/queue/clear', {}); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue new file mode 100644 index 0000000000..fd0ce97d57 --- /dev/null +++ b/packages/client/src/pages/admin/relays.vue @@ -0,0 +1,99 @@ +<template> +<FormBase class="relaycxt"> + <FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton> + + <div class="_debobigegoItem" v-for="relay in relays" :key="relay.inbox"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <div>{{ relay.inbox }}</div> + <div>{{ $t(`_relayStatus.${relay.status}`) }}</div> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton> + </div> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + MkButton, + MkInput, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.relays, + icon: 'fas fa-globe', + bg: 'var(--bg)', + }, + relays: [], + inbox: '', + } + }, + + created() { + this.refresh(); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async addRelay() { + const { canceled, result: inbox } = await os.dialog({ + title: this.$ts.addRelay, + input: { + placeholder: this.$ts.inboxUrl + } + }); + if (canceled) return; + os.api('admin/relays/add', { + inbox + }).then((relay: any) => { + this.refresh(); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message || e + }); + }); + }, + + remove(inbox: string) { + os.api('admin/relays/remove', { + inbox + }).then(() => { + this.refresh(); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message || e + }); + }); + }, + + refresh() { + os.api('admin/relays/list').then((relays: any) => { + this.relays = relays; + }); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue new file mode 100644 index 0000000000..ad53ec4fcf --- /dev/null +++ b/packages/client/src/pages/admin/security.vue @@ -0,0 +1,83 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormLink to="/admin/bot-protection"> + <i class="fas fa-shield-alt"></i> {{ $ts.botProtection }} + <template #suffix v-if="enableHcaptcha">hCaptcha</template> + <template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template> + <template #suffix v-else>{{ $ts.none }} ({{ $ts.notRecommended }})</template> + </FormLink> + + <FormSwitch v-model="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch> + + <FormSwitch v-model="emailRequiredForSignup">{{ $ts.emailRequiredForSignup }}</FormSwitch> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormLink, + FormSwitch, + FormBase, + FormGroup, + FormButton, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.security, + icon: 'fas fa-lock', + bg: 'var(--bg)', + }, + enableHcaptcha: false, + enableRecaptcha: false, + enableRegistration: false, + emailRequiredForSignup: false, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableHcaptcha = meta.enableHcaptcha; + this.enableRecaptcha = meta.enableRecaptcha; + this.enableRegistration = !meta.disableRegistration; + this.emailRequiredForSignup = meta.emailRequiredForSignup; + }, + + save() { + os.apiWithDialog('admin/update-meta', { + disableRegistration: !this.enableRegistration, + emailRequiredForSignup: this.emailRequiredForSignup, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/service-worker.vue b/packages/client/src/pages/admin/service-worker.vue new file mode 100644 index 0000000000..9e91d6d64f --- /dev/null +++ b/packages/client/src/pages/admin/service-worker.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormSwitch v-model="enableServiceWorker"> + {{ $ts.enableServiceworker }} + <template #desc>{{ $ts.serviceworkerInfo }}</template> + </FormSwitch> + + <template v-if="enableServiceWorker"> + <FormInput v-model="swPublicKey"> + <template #prefix><i class="fas fa-key"></i></template> + Public key + </FormInput> + + <FormInput v-model="swPrivateKey"> + <template #prefix><i class="fas fa-key"></i></template> + Private key + </FormInput> + </template> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'ServiceWorker', + icon: 'fas fa-bolt', + bg: 'var(--bg)', + }, + enableServiceWorker: false, + swPublicKey: null, + swPrivateKey: null, + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.enableServiceWorker = meta.enableServiceWorker; + this.swPublicKey = meta.swPublickey; + this.swPrivateKey = meta.swPrivateKey; + }, + save() { + os.apiWithDialog('admin/update-meta', { + enableServiceWorker: this.enableServiceWorker, + swPublicKey: this.swPublicKey, + swPrivateKey: this.swPrivateKey, + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue new file mode 100644 index 0000000000..66aa3e21db --- /dev/null +++ b/packages/client/src/pages/admin/settings.vue @@ -0,0 +1,151 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormInput v-model="name"> + <span>{{ $ts.instanceName }}</span> + </FormInput> + + <FormTextarea v-model="description"> + <span>{{ $ts.instanceDescription }}</span> + </FormTextarea> + + <FormInput v-model="iconUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.iconUrl }}</span> + </FormInput> + + <FormInput v-model="bannerUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.bannerUrl }}</span> + </FormInput> + + <FormInput v-model="backgroundImageUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.backgroundImageUrl }}</span> + </FormInput> + + <FormInput v-model="tosUrl"> + <template #prefix><i class="fas fa-link"></i></template> + <span>{{ $ts.tosUrl }}</span> + </FormInput> + + <FormInput v-model="maintainerName"> + <span>{{ $ts.maintainerName }}</span> + </FormInput> + + <FormInput v-model="maintainerEmail" type="email"> + <template #prefix><i class="fas fa-envelope"></i></template> + <span>{{ $ts.maintainerEmail }}</span> + </FormInput> + + <FormTextarea v-model="pinnedUsers"> + <span>{{ $ts.pinnedUsers }}</span> + <template #desc>{{ $ts.pinnedUsersDescription }}</template> + </FormTextarea> + + <FormInput v-model="maxNoteTextLength" type="number"> + <template #prefix><i class="fas fa-pencil-alt"></i></template> + <span>{{ $ts.maxNoteTextLength }}</span> + </FormInput> + + <FormSwitch v-model="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch> + <FormSwitch v-model="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch> + <FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo> + + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { fetchInstance } from '@/instance'; + +export default defineComponent({ + components: { + FormSwitch, + FormInput, + FormBase, + FormGroup, + FormButton, + FormTextarea, + FormInfo, + FormSuspense, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.general, + icon: 'fas fa-cog', + bg: 'var(--bg)', + }, + name: null, + description: null, + tosUrl: null as string | null, + maintainerName: null, + maintainerEmail: null, + iconUrl: null, + bannerUrl: null, + backgroundImageUrl: null, + maxNoteTextLength: 0, + enableLocalTimeline: false, + enableGlobalTimeline: false, + pinnedUsers: '', + } + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async init() { + const meta = await os.api('meta', { detail: true }); + this.name = meta.name; + this.description = meta.description; + this.tosUrl = meta.tosUrl; + this.iconUrl = meta.iconUrl; + this.bannerUrl = meta.bannerUrl; + this.backgroundImageUrl = meta.backgroundImageUrl; + this.maintainerName = meta.maintainerName; + this.maintainerEmail = meta.maintainerEmail; + this.maxNoteTextLength = meta.maxNoteTextLength; + this.enableLocalTimeline = !meta.disableLocalTimeline; + this.enableGlobalTimeline = !meta.disableGlobalTimeline; + this.pinnedUsers = meta.pinnedUsers.join('\n'); + }, + + save() { + os.apiWithDialog('admin/update-meta', { + name: this.name, + description: this.description, + tosUrl: this.tosUrl, + iconUrl: this.iconUrl, + bannerUrl: this.bannerUrl, + backgroundImageUrl: this.backgroundImageUrl, + maintainerName: this.maintainerName, + maintainerEmail: this.maintainerEmail, + maxNoteTextLength: this.maxNoteTextLength, + disableLocalTimeline: !this.enableLocalTimeline, + disableGlobalTimeline: !this.enableGlobalTimeline, + pinnedUsers: this.pinnedUsers.split('\n'), + }).then(() => { + fetchInstance(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue new file mode 100644 index 0000000000..f4a2ffa6d2 --- /dev/null +++ b/packages/client/src/pages/admin/users.vue @@ -0,0 +1,254 @@ +<template> +<div class="lknzcolw"> + <div class="users"> + <div class="inputs"> + <MkSelect v-model="sort" style="flex: 1;"> + <template #label>{{ $ts.sort }}</template> + <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> + <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> + <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model="state" style="flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="available">{{ $ts.normal }}</option> + <option value="admin">{{ $ts.administrator }}</option> + <option value="moderator">{{ $ts.moderator }}</option> + <option value="silenced">{{ $ts.silence }}</option> + <option value="suspended">{{ $ts.suspend }}</option> + </MkSelect> + <MkSelect v-model="origin" style="flex: 1;"> + <template #label>{{ $ts.instance }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + </div> + <div class="inputs"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ $ts.username }}</template> + </MkInput> + <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()" :disabled="pagination.params().origin === 'local'"> + <template #prefix>@</template> + <template #label>{{ $ts.host }}</template> + </MkInput> + </div> + + <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users"> + <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <header> + <MkUserName class="name" :user="user"/> + <span class="acct">@{{ acct(user) }}</span> + <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span> + <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span> + <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span> + <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span> + </header> + <div> + <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> + </div> + <div> + <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span> + </div> + </div> + </button> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { lookupUser } from '@/scripts/lookup-user'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.users, + icon: 'fas fa-users', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-search', + text: this.$ts.search, + handler: this.searchUser + }, { + asFullButton: true, + icon: 'fas fa-plus', + text: this.$ts.addUser, + handler: this.addUser + }, { + asFullButton: true, + icon: 'fas fa-search', + text: this.$ts.lookup, + handler: this.lookupUser + }], + }, + sort: '+createdAt', + state: 'all', + origin: 'local', + searchUsername: '', + searchHost: '', + pagination: { + endpoint: 'admin/show-users', + limit: 10, + params: () => ({ + sort: this.sort, + state: this.state, + origin: this.origin, + username: this.searchUsername, + hostname: this.searchHost, + }), + offsetMode: true + }, + } + }, + + watch: { + sort() { + this.$refs.users.reload(); + }, + state() { + this.$refs.users.reload(); + }, + origin() { + this.$refs.users.reload(); + }, + }, + + async mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + lookupUser, + + searchUser() { + os.selectUser().then(user => { + this.show(user); + }); + }, + + async addUser() { + const { canceled: canceled1, result: username } = await os.dialog({ + title: this.$ts.username, + input: true + }); + if (canceled1) return; + + const { canceled: canceled2, result: password } = await os.dialog({ + title: this.$ts.password, + input: { type: 'password' } + }); + if (canceled2) return; + + os.apiWithDialog('admin/accounts/create', { + username: username, + password: password, + }).then(res => { + this.$refs.users.reload(); + }); + }, + + show(user) { + os.pageWindow(`/user-info/${user.id}`); + }, + + acct + } +}); +</script> + +<style lang="scss" scoped> +.lknzcolw { + > .users { + margin: var(--margin); + + > .inputs { + display: flex; + margin-bottom: 16px; + + > * { + margin-right: 16px; + + &:last-child { + margin-right: 0; + } + } + } + + > .users { + margin-top: var(--margin); + + > .user { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + padding: 16px; + + &:hover { + color: var(--accent); + } + + > .avatar { + width: 60px; + height: 60px; + } + + > .body { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + + > header { + > .name { + font-weight: bold; + } + + > .acct { + margin-left: 8px; + opacity: 0.7; + } + + > .staff { + margin-left: 0.5em; + color: var(--badge); + } + + > .punished { + margin-left: 0.5em; + color: #4dabf7; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/advanced-theme-editor.vue b/packages/client/src/pages/advanced-theme-editor.vue new file mode 100644 index 0000000000..eebfc21b3f --- /dev/null +++ b/packages/client/src/pages/advanced-theme-editor.vue @@ -0,0 +1,352 @@ +<template> +<div class="t9makv94"> + <section class="_section"> + <div class="_content"> + <details> + <summary>{{ $ts.import }}</summary> + <MkTextarea v-model="themeToImport"> + {{ $ts._theme.importInfo }} + </MkTextarea> + <MkButton :disabled="!themeToImport.trim()" @click="importTheme">{{ $ts.import }}</MkButton> + </details> + </div> + </section> + <section class="_section"> + <div class="_content _card _gap"> + <div class="_content"> + <MkInput v-model="name" required><span>{{ $ts.name }}</span></MkInput> + <MkInput v-model="author" required><span>{{ $ts.author }}</span></MkInput> + <MkTextarea v-model="description"><span>{{ $ts.description }}</span></MkTextarea> + <div class="_inputs"> + <div v-text="$ts._theme.base" /> + <MkRadio v-model="baseTheme" value="light">{{ $ts.light }}</MkRadio> + <MkRadio v-model="baseTheme" value="dark">{{ $ts.dark }}</MkRadio> + </div> + </div> + </div> + <div class="_content _card _gap"> + <div class="list-view _content"> + <div class="item" v-for="([ k, v ], i) in theme" :key="k"> + <div class="_inputs"> + <div> + {{ k.startsWith('$') ? `${k} (${$ts._theme.constant})` : $t('_theme.keys.' + k) }} + <button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$ts.delete" /> + </div> + <div> + <div class="type" @click="chooseType($event, i)"> + {{ getTypeOf(v) }} <i class="fas fa-chevron-down"></i> + </div> + <!-- default --> + <div v-if="v === null" v-text="baseProps[k]" class="default-value" /> + <!-- color --> + <div v-else-if="typeof v === 'string'" class="color"> + <input type="color" :value="v" @input="colorChanged($event.target.value, i)"/> + <MkInput class="select" :value="v" @update:modelValue="colorChanged($event, i)"/> + </div> + <!-- ref const --> + <MkInput v-else-if="v.type === 'refConst'" v-model="v.key"> + <template #prefix>$</template> + <span>{{ $ts.name }}</span> + </MkInput> + <!-- ref props --> + <MkSelect class="select" v-else-if="v.type === 'refProp'" v-model="v.key"> + <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> + </MkSelect> + <!-- func --> + <template v-else-if="v.type === 'func'"> + <MkSelect class="select" v-model="v.name"> + <template #label>{{ $ts._theme.funcKind }}</template> + <option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option> + </MkSelect> + <MkInput type="number" v-model="v.arg"><span>{{ $ts._theme.argument }}</span></MkInput> + <MkSelect class="select" v-model="v.value"> + <template #label>{{ $ts._theme.basedProp }}</template> + <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option> + </MkSelect> + </template> + <!-- CSS --> + <MkInput v-else-if="v.type === 'css'" v-model="v.value"> + <span>CSS</span> + </MkInput> + </div> + </div> + </div> + <MkButton primary @click="addConst">{{ $ts._theme.addConstant }}</MkButton> + </div> + </div> + </section> + <section class="_section"> + <details class="_content"> + <summary>{{ $ts.sample }}</summary> + <MkSample/> + </details> + </section> + <section class="_section"> + <div class="_content"> + <MkButton inline @click="preview">{{ $ts.preview }}</MkButton> + <MkButton inline primary :disabled="!name || !author" @click="save">{{ $ts.save }}</MkButton> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import { toUnicode } from 'punycode/'; + +import MkRadio from '@/components/form/radio.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSample from '@/components/sample.vue'; + +import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@/scripts/theme-editor'; +import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@/scripts/theme'; +import { host } from '@/config'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { addTheme } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkRadio, + MkButton, + MkInput, + MkTextarea, + MkSelect, + MkSample, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.themeEditor, + icon: 'fas fa-palette', + }, + theme: [] as ThemeViewModel, + name: '', + description: '', + baseTheme: 'light' as 'dark' | 'light', + author: `@${this.$i.username}@${toUnicode(host)}`, + themeToImport: '', + changed: false, + lightTheme, darkTheme, themeProps, + } + }, + + computed: { + baseProps() { + return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props; + }, + }, + + beforeUnmount() { + window.removeEventListener('beforeunload', this.beforeunload); + }, + + async beforeRouteLeave(to, from, next) { + if (this.changed && !(await this.confirm())) { + next(false); + } else { + next(); + } + }, + + mounted() { + this.init(); + window.addEventListener('beforeunload', this.beforeunload); + const changed = () => this.changed = true; + this.$watch('name', changed); + this.$watch('description', changed); + this.$watch('baseTheme', changed); + this.$watch('author', changed); + this.$watch('theme', changed); + }, + + methods: { + beforeunload(e: BeforeUnloadEvent) { + if (this.changed) { + e.preventDefault(); + e.returnValue = ''; + } + }, + + async confirm(): Promise<boolean> { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.leaveConfirm, + showCancelButton: true + }); + return !canceled; + }, + + init() { + const t: ThemeViewModel = []; + for (const key of themeProps) { + t.push([ key, null ]); + } + this.theme = t; + }, + + async del(i: number) { + const { canceled } = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }), + }); + if (canceled) return; + Vue.delete(this.theme, i); + }, + + async addConst() { + const { canceled, result } = await os.dialog({ + title: this.$ts._theme.inputConstantName, + input: true + }); + if (canceled) return; + this.theme.push([ '$' + result, '#000000']); + }, + + save() { + const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme); + addTheme(theme); + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + this.changed = false; + }, + + preview() { + const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme); + try { + applyTheme(theme, false); + } catch (e) { + os.dialog({ + type: 'error', + text: e.message + }); + } + }, + + async importTheme() { + if (this.changed && (!await this.confirm())) return; + + try { + const theme = JSON5.parse(this.themeToImport) as Theme; + if (!validateTheme(theme)) throw new Error(this.$ts._theme.invalid); + + this.name = theme.name; + this.description = theme.desc || ''; + this.author = theme.author; + this.baseTheme = theme.base || 'light'; + this.theme = convertToViewModel(theme); + this.themeToImport = ''; + } catch (e) { + os.dialog({ + type: 'error', + text: e.message + }); + } + }, + + colorChanged(color: string, i: number) { + this.theme[i] = [this.theme[i][0], color]; + }, + + getTypeOf(v: ThemeValue) { + return v === null + ? this.$ts._theme.defaultValue + : typeof v === 'string' + ? this.$ts._theme.color + : this.$t('_theme.' + v.type); + }, + + async chooseType(e: MouseEvent, i: number) { + const newValue = await this.showTypeMenu(e); + this.theme[i] = [ this.theme[i][0], newValue ]; + }, + + showTypeMenu(e: MouseEvent) { + return new Promise<ThemeValue>((resolve) => { + os.popupMenu([{ + text: this.$ts._theme.defaultValue, + action: () => resolve(null), + }, { + text: this.$ts._theme.color, + action: () => resolve('#000000'), + }, { + text: this.$ts._theme.func, + action: () => resolve({ + type: 'func', name: 'alpha', arg: 1, value: 'accent' + }), + }, { + text: this.$ts._theme.refProp, + action: () => resolve({ + type: 'refProp', key: 'accent', + }), + }, { + text: this.$ts._theme.refConst, + action: () => resolve({ + type: 'refConst', key: '', + }), + }, { + text: 'CSS', + action: () => resolve({ + type: 'css', value: '', + }), + }], e.currentTarget || e.target); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.t9makv94 { + > ._section { + > ._content { + > .list-view { + > .item { + min-height: 48px; + word-break: break-all; + + &:not(:last-child) { + margin-bottom: 8px; + } + + .select { + margin: 24px 0; + } + + .type { + cursor: pointer; + } + + .default-value { + opacity: 0.6; + pointer-events: none; + user-select: none; + } + + .color { + > input { + display: inline-block; + width: 1.5em; + height: 1.5em; + } + + > div { + margin-left: 8px; + display: inline-block; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue new file mode 100644 index 0000000000..946b368733 --- /dev/null +++ b/packages/client/src/pages/announcements.vue @@ -0,0 +1,74 @@ +<template> +<MkSpacer :content-max="800"> + <MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content"> + <section class="_card announcement" v-for="(announcement, i) in items" :key="announcement.id"> + <div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> + <div class="_content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + </div> + <div class="_footer" v-if="$i && !announcement.isRead"> + <MkButton @click="read(items, announcement, i)" primary><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton> + </div> + </section> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.announcements, + icon: 'fas fa-broadcast-tower', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'announcements', + limit: 10, + }, + }; + }, + + methods: { + // TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい + read(items, announcement, i) { + items[i] = { + ...announcement, + isRead: true, + }; + os.api('i/read-announcement', { announcementId: announcement.id }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.ruryvtyk { + > .announcement { + &:not(:last-child) { + margin-bottom: var(--margin); + } + + > ._content { + > img { + display: block; + max-height: 300px; + max-width: 100%; + } + } + } +} +</style> diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue new file mode 100644 index 0000000000..f7f6990fa8 --- /dev/null +++ b/packages/client/src/pages/antenna-timeline.vue @@ -0,0 +1,147 @@ +<template> +<div class="tqmomfks" v-hotkey.global="keymap" v-size="{ min: [800] }"> + <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline ref="tl" class="tl" + :key="antennaId" + src="antenna" + :antenna="antennaId" + :sound="true" + @before="before()" + @after="after()" + @queue="queueUpdated" + /> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import Progress from '@/scripts/loading'; +import XTimeline from '@/components/timeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XTimeline, + }, + + props: { + antennaId: { + type: String, + required: true + } + }, + + data() { + return { + antenna: null, + queue: 0, + [symbols.PAGE_INFO]: computed(() => this.antenna ? { + title: this.antenna.name, + icon: 'fas fa-satellite', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }, { + icon: 'fas fa-cog', + text: this.$ts.settings, + handler: this.settings + }], + } : null), + }; + }, + + computed: { + keymap(): any { + return { + 't': this.focus + }; + }, + }, + + watch: { + antennaId: { + async handler() { + this.antenna = await os.api('antennas/show', { + antennaId: this.antennaId + }); + }, + immediate: true + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + queueUpdated(q) { + this.queue = q; + }, + + top() { + scroll(this.$el, { top: 0 }); + }, + + async timetravel() { + const { canceled, result: date } = await os.dialog({ + title: this.$ts.date, + input: { + type: 'date' + } + }); + if (canceled) return; + + this.$refs.tl.timetravel(new Date(date)); + }, + + settings() { + this.$router.push(`/my/antennas/${this.antennaId}`); + }, + + focus() { + (this.$refs.tl as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.tqmomfks { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue new file mode 100644 index 0000000000..48495df3c2 --- /dev/null +++ b/packages/client/src/pages/api-console.vue @@ -0,0 +1,93 @@ +<template> +<div class="_root"> + <div class="_block" style="padding: 24px;"> + <MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()" class=""> + <template #label>Endpoint</template> + </MkInput> + <MkTextarea v-model="body" code> + <template #label>Params (JSON or JSON5)</template> + </MkTextarea> + <MkSwitch v-model="withCredential"> + With credential + </MkSwitch> + <MkButton primary full @click="send" :disabled="sending"> + <template v-if="sending"><MkEllipsis/></template> + <template v-else><i class="fas fa-paper-plane"></i> Send</template> + </MkButton> + </div> + <div v-if="res" class="_block" style="padding: 24px;"> + <MkTextarea v-model="res" code readonly tall> + <template #label>Response</template> + </MkTextarea> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, MkInput, MkTextarea, MkSwitch, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'API console', + icon: 'fas fa-terminal' + }, + + endpoint: '', + body: '{}', + res: null, + sending: false, + endpoints: [], + withCredential: true, + + }; + }, + + created() { + os.api('endpoints').then(endpoints => { + this.endpoints = endpoints; + }); + }, + + methods: { + send() { + this.sending = true; + os.api(this.endpoint, JSON5.parse(this.body)).then(res => { + this.sending = false; + this.res = JSON5.stringify(res, null, 2); + }, err => { + this.sending = false; + this.res = JSON5.stringify(err, null, 2); + }); + }, + + onEndpointChange() { + os.api('endpoint', { endpoint: this.endpoint }, this.withCredential ? undefined : null).then(endpoint => { + const body = {}; + for (const p of endpoint.params) { + body[p.name] = + p.type === 'String' ? '' : + p.type === 'Number' ? 0 : + p.type === 'Boolean' ? false : + p.type === 'Array' ? [] : + p.type === 'Object' ? {} : + null; + } + this.body = JSON5.stringify(body, null, 2); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/auth.form.vue b/packages/client/src/pages/auth.form.vue new file mode 100644 index 0000000000..8b2adc3e07 --- /dev/null +++ b/packages/client/src/pages/auth.form.vue @@ -0,0 +1,60 @@ +<template> +<section class="_section"> + <div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div> + <div class="_content"> + <h2>{{ app.name }}</h2> + <p class="id">{{ app.id }}</p> + <p class="description">{{ app.description }}</p> + </div> + <div class="_content"> + <h2>{{ $ts._auth.permissionAsk }}</h2> + <ul> + <li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton @click="cancel" inline>{{ $ts.cancel }}</MkButton> + <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton + }, + props: ['session'], + computed: { + name(): string { + const el = document.createElement('div'); + el.textContent = this.app.name + return el.innerHTML; + }, + app(): any { + return this.session.app; + } + }, + methods: { + cancel() { + os.api('auth/deny', { + token: this.session.token + }).then(() => { + this.$emit('denied'); + }); + }, + + accept() { + os.api('auth/accept', { + token: this.session.token + }).then(() => { + this.$emit('accepted'); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue new file mode 100644 index 0000000000..522bd4cdf8 --- /dev/null +++ b/packages/client/src/pages/auth.vue @@ -0,0 +1,95 @@ +<template> +<div class="" v-if="$i && fetching"> + <MkLoading/> +</div> +<div v-else-if="$i"> + <XForm + class="form" + ref="form" + v-if="state == 'waiting'" + :session="session" + @denied="state = 'denied'" + @accepted="accepted" + /> + <div class="denied" v-if="state == 'denied'"> + <h1>{{ $ts._auth.denied }}</h1> + </div> + <div class="accepted" v-if="state == 'accepted'"> + <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$ts.allowed }}</h1> + <p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p> + <p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p> + </div> + <div class="error" v-if="state == 'fetch-session-error'"> + <p>{{ $ts.somethingHappened }}</p> + </div> +</div> +<div class="signin" v-else> + <MkSignin @login="onLogin"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XForm from './auth.form.vue'; +import MkSignin from '@/components/signin.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + XForm, + MkSignin, + }, + data() { + return { + state: null, + session: null, + fetching: true + }; + }, + computed: { + token(): string { + return this.$route.params.token; + } + }, + mounted() { + if (!this.$i) return; + + // Fetch session + os.api('auth/session/show', { + token: this.token + }).then(session => { + this.session = session; + this.fetching = false; + + // 既に連携していた場合 + if (this.session.app.isAuthorized) { + os.api('auth/accept', { + token: this.session.token + }).then(() => { + this.accepted(); + }); + } else { + this.state = 'waiting'; + } + }).catch(error => { + this.state = 'fetch-session-error'; + this.fetching = false; + }); + }, + methods: { + accepted() { + this.state = 'accepted'; + if (this.session.app.callbackUrl) { + location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; + } + }, onLogin(res) { + login(res.i); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue new file mode 100644 index 0000000000..e2cf8b9f00 --- /dev/null +++ b/packages/client/src/pages/channel-editor.vue @@ -0,0 +1,129 @@ +<template> +<div> + <div class="_section"> + <div class="_content"> + <MkInput v-model="name"> + <template #label>{{ $ts.name }}</template> + </MkInput> + + <MkTextarea v-model="description"> + <template #label>{{ $ts.description }}</template> + </MkTextarea> + + <div class="banner"> + <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton> + <div v-else-if="bannerUrl"> + <img :src="bannerUrl" style="width: 100%;"/> + <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton> + </div> + </div> + </div> + <div class="_footer"> + <MkButton @click="save()" primary><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkTextarea, MkButton, MkInput, + }, + + props: { + channelId: { + type: String, + required: false + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.channelId ? { + title: this.$ts._channel.edit, + icon: 'fas fa-satellite-dish', + } : { + title: this.$ts._channel.create, + icon: 'fas fa-satellite-dish', + }), + channel: null, + name: null, + description: null, + bannerUrl: null, + bannerId: null, + }; + }, + + watch: { + async bannerId() { + if (this.bannerId == null) { + this.bannerUrl = null; + } else { + this.bannerUrl = (await os.api('drive/files/show', { + fileId: this.bannerId, + })).url; + } + }, + }, + + async created() { + if (this.channelId) { + this.channel = await os.api('channels/show', { + channelId: this.channelId, + }); + + this.name = this.channel.name; + this.description = this.channel.description; + this.bannerId = this.channel.bannerId; + this.bannerUrl = this.channel.bannerUrl; + } + }, + + methods: { + save() { + const params = { + name: this.name, + description: this.description, + bannerId: this.bannerId, + }; + + if (this.channelId) { + params.channelId = this.channelId; + os.api('channels/update', params) + .then(channel => { + os.success(); + }); + } else { + os.api('channels/create', params) + .then(channel => { + os.success(); + this.$router.push(`/channels/${channel.id}`); + }); + } + }, + + setBannerImage(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + this.bannerId = file.id; + }); + }, + + removeBannerImage() { + this.bannerId = null; + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue new file mode 100644 index 0000000000..f9a9ca29e9 --- /dev/null +++ b/packages/client/src/pages/channel.vue @@ -0,0 +1,186 @@ +<template> +<div v-if="channel" class="_section"> + <div class="wpgynlbz _content _panel _gap" :class="{ hide: !showBanner }"> + <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> + <button class="_button toggle" @click="() => showBanner = !showBanner"> + <template v-if="showBanner"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + <div class="hideOverlay" v-if="!showBanner"> + </div> + <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> + <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 class="fade"></div> + </div> + <div class="description" v-if="channel.description"> + <Mfm :text="channel.description" :is-note="false" :i="$i"/> + </div> + </div> + + <XPostForm :channel="channel" class="post-form _content _panel _gap" fixed v-if="$i"/> + + <XTimeline class="_content _gap" src="channel" :key="channelId" :channel="channelId" @before="before" @after="after"/> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import XPostForm from '@/components/post-form.vue'; +import XTimeline from '@/components/timeline.vue'; +import XChannelFollowButton from '@/components/channel-follow-button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkContainer, + XPostForm, + XTimeline, + XChannelFollowButton + }, + + props: { + channelId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.channel ? { + title: this.channel.name, + icon: 'fas fa-satellite-dish', + } : null), + channel: null, + showBanner: true, + pagination: { + endpoint: 'channels/timeline', + limit: 10, + params: () => ({ + channelId: this.channelId, + }) + }, + }; + }, + + watch: { + channelId: { + async handler() { + this.channel = await os.api('channels/show', { + channelId: this.channelId, + }); + }, + immediate: true + } + }, + + created() { + + }, +}); +</script> + +<style lang="scss" scoped> +.wpgynlbz { + position: relative; + + > .subscribe { + position: absolute; + z-index: 1; + top: 16px; + left: 16px; + } + + > .toggle { + position: absolute; + z-index: 2; + top: 8px; + right: 8px; + font-size: 1.2em; + width: 48px; + height: 48px; + color: #fff; + background: rgba(0, 0, 0, 0.5); + border-radius: 100%; + + > i { + vertical-align: middle; + } + } + + > .banner { + position: relative; + 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)); + } + + > .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; + } + } + + > .description { + padding: 16px; + } + + > .hideOverlay { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + -webkit-backdrop-filter: var(--blur, blur(16px)); + backdrop-filter: var(--blur, blur(16px)); + background: rgba(0, 0, 0, 0.3); + } + + &.hide { + > .subscribe { + display: none; + } + + > .toggle { + top: 0; + right: 0; + height: 100%; + background: transparent; + } + + > .banner { + height: 42px; + filter: blur(8px); + + > * { + display: none; + } + } + + > .description { + display: none; + } + } +} +</style> diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue new file mode 100644 index 0000000000..09e136ac00 --- /dev/null +++ b/packages/client/src/pages/channels.vue @@ -0,0 +1,77 @@ +<template> +<div> + <div class="_section" style="padding: 0;" v-if="$i"> + <MkTab class="_content" v-model="tab"> + <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._channel.featured }}</option> + <option value="following"><i class="fas fa-heart"></i> {{ $ts._channel.following }}</option> + <option value="owned"><i class="fas fa-edit"></i> {{ $ts._channel.owned }}</option> + </MkTab> + </div> + + <div class="_section"> + <div class="_content grwlizim featured" v-if="tab === 'featured'"> + <MkPagination :pagination="featuredPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> + + <div class="_content grwlizim following" v-if="tab === 'following'"> + <MkPagination :pagination="followingPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> + + <div class="_content grwlizim owned" v-if="tab === 'owned'"> + <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> + <MkPagination :pagination="ownedPagination" #default="{items}"> + <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkChannelPreview from '@/components/channel-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkChannelPreview, MkPagination, MkButton, MkTab + }, + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.channel, + icon: 'fas fa-satellite-dish', + action: { + icon: 'fas fa-plus', + handler: this.create + } + }, + tab: 'featured', + featuredPagination: { + endpoint: 'channels/featured', + noPaging: true, + }, + followingPagination: { + endpoint: 'channels/followed', + limit: 5, + }, + ownedPagination: { + endpoint: 'channels/owned', + limit: 5, + }, + }; + }, + methods: { + create() { + this.$router.push(`/channels/new`); + } + } +}); +</script> diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue new file mode 100644 index 0000000000..510a73ce68 --- /dev/null +++ b/packages/client/src/pages/clip.vue @@ -0,0 +1,154 @@ +<template> +<div v-if="clip" class="_section"> + <div class="okzinsic _content _panel _gap"> + <div class="description" v-if="clip.description"> + <Mfm :text="clip.description" :is-note="false" :i="$i"/> + </div> + <div class="user"> + <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/> + </div> + </div> + + <XNotes class="_content _gap" :pagination="pagination" :detail="true"/> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import XPostForm from '@/components/post-form.vue'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkContainer, + XPostForm, + XNotes, + }, + + props: { + clipId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.clip ? { + title: this.clip.name, + icon: 'fas fa-paperclip', + action: { + icon: 'fas fa-ellipsis-h', + handler: this.menu + } + } : null), + clip: null, + pagination: { + endpoint: 'clips/notes', + limit: 10, + params: () => ({ + clipId: this.clipId, + }) + }, + }; + }, + + computed: { + isOwned(): boolean { + return this.$i && this.clip && (this.$i.id === this.clip.userId); + } + }, + + watch: { + clipId: { + async handler() { + this.clip = await os.api('clips/show', { + clipId: this.clipId, + }); + }, + immediate: true + } + }, + + created() { + + }, + + methods: { + menu(ev) { + os.popupMenu([this.isOwned ? { + icon: 'fas fa-pencil-alt', + text: this.$ts.edit, + action: async () => { + const { canceled, result } = await os.form(this.clip.name, { + name: { + type: 'string', + label: this.$ts.name, + default: this.clip.name + }, + description: { + type: 'string', + required: false, + multiline: true, + label: this.$ts.description, + default: this.clip.description + }, + isPublic: { + type: 'boolean', + label: this.$ts.public, + default: this.clip.isPublic + } + }); + if (canceled) return; + + os.apiWithDialog('clips/update', { + clipId: this.clip.id, + ...result + }); + } + } : undefined, this.isOwned ? { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: async () => { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('deleteAreYouSure', { x: this.clip.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.apiWithDialog('clips/delete', { + clipId: this.clip.id, + }); + } + } : undefined], ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.okzinsic { + position: relative; + + > .description { + padding: 16px; + } + + > .user { + $height: 32px; + padding: 16px; + border-top: solid 0.5px var(--divider); + line-height: $height; + + > .avatar { + width: $height; + height: $height; + } + } +} +</style> diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue new file mode 100644 index 0000000000..5d7d3b2f5a --- /dev/null +++ b/packages/client/src/pages/drive.vue @@ -0,0 +1,28 @@ +<template> +<div> + <XDrive ref="drive" @cd="x => folder = x"/> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XDrive from '@/components/drive.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XDrive + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: computed(() => this.folder ? this.folder.name : this.$ts.drive), + icon: 'fas fa-cloud', + }, + folder: null, + }; + }, +}); +</script> diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/emojis.category.vue new file mode 100644 index 0000000000..327cbce7e8 --- /dev/null +++ b/packages/client/src/pages/emojis.category.vue @@ -0,0 +1,135 @@ +<template> +<div class="driuhtrh"> + <div class="query"> + <MkInput v-model="q" class="" :placeholder="$ts.search"> + <template #prefix><i class="fas fa-search"></i></template> + </MkInput> + + <!-- たくさんあると邪魔 + <div class="tags"> + <span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> + </div> + --> + </div> + + <MkFolder class="emojis" v-if="searchEmojis"> + <template #header>{{ $ts.searchResult }}</template> + <div class="zuvgdzyt"> + <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/> + </div> + </MkFolder> + + <MkFolder class="emojis" v-for="category in customEmojiCategories" :key="category"> + <template #header>{{ category || $ts.other }}</template> + <div class="zuvgdzyt"> + <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/> + </div> + </MkFolder> +</div> +</template> + +<script lang="ts"> +import { defineComponent, computed } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { emojiCategories, emojiTags } from '@/instance'; +import XEmoji from './emojis.emoji.vue'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkFolder, + MkTab, + XEmoji, + }, + + data() { + return { + q: '', + customEmojiCategories: emojiCategories, + customEmojis: this.$instance.emojis, + tags: emojiTags, + selectedTags: new Set(), + searchEmojis: null, + } + }, + + watch: { + q() { this.search(); }, + selectedTags: { + handler() { + this.search(); + }, + deep: true + }, + }, + + methods: { + search() { + if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) { + this.searchEmojis = null; + return; + } + + if (this.selectedTags.size === 0) { + this.searchEmojis = this.customEmojis.filter(e => e.name.includes(this.q) || e.aliases.includes(this.q)); + } else { + this.searchEmojis = this.customEmojis.filter(e => (e.name.includes(this.q) || e.aliases.includes(this.q)) && [...this.selectedTags].every(t => e.aliases.includes(t))); + } + }, + + toggleTag(tag) { + if (this.selectedTags.has(tag)) { + this.selectedTags.delete(tag); + } else { + this.selectedTags.add(tag); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.driuhtrh { + background: var(--bg); + + > .query { + background: var(--bg); + padding: 16px; + + > .tags { + > .tag { + display: inline-block; + margin: 8px 8px 0 0; + padding: 4px 8px; + font-size: 0.9em; + background: var(--accentedBg); + border-radius: 5px; + + &.active { + background: var(--accent); + color: var(--fgOnAccent); + } + } + } + } + + > .emojis { + --x-padding: 0 16px; + + .zuvgdzyt { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + grid-gap: 12px; + margin: 0 var(--margin) var(--margin) var(--margin); + } + } +} +</style> diff --git a/packages/client/src/pages/emojis.emoji.vue b/packages/client/src/pages/emojis.emoji.vue new file mode 100644 index 0000000000..4ca7c15742 --- /dev/null +++ b/packages/client/src/pages/emojis.emoji.vue @@ -0,0 +1,94 @@ +<template> +<button class="zuvgdzyu _button" @click="menu"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.aliases.join(' ') }}</div> + </div> +</button> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import VanillaTilt from 'vanilla-tilt'; + +export default defineComponent({ + props: { + emoji: { + type: Object, + required: true, + } + }, + + mounted() { + if (this.$store.animation) { + VanillaTilt.init(this.$el, { + reverse: true, + gyroscope: false, + scale: 1.1, + speed: 500, + }); + } + }, + + methods: { + menu(ev) { + os.popupMenu([{ + type: 'label', + text: ':' + this.emoji.name + ':', + }, { + text: this.$ts.copy, + icon: 'fas fa-copy', + action: () => { + copyToClipboard(`:${this.emoji.name}:`); + os.success(); + } + }], ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.zuvgdzyu { + display: flex; + align-items: center; + padding: 12px; + text-align: left; + background: var(--panel); + border-radius: 8px; + transform-style: preserve-3d; + transform: perspective(1000px); + + &:hover { + border-color: var(--accent); + } + + > .img { + width: 42px; + height: 42px; + transform: translateZ(20px); + } + + > .body { + padding: 0 0 0 8px; + white-space: nowrap; + overflow: hidden; + transform: translateZ(10px); + + > .name { + text-overflow: ellipsis; + overflow: hidden; + } + + > .info { + opacity: 0.5; + font-size: 0.9em; + text-overflow: ellipsis; + overflow: hidden; + } + } +} +</style> diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue new file mode 100644 index 0000000000..ae06fa7938 --- /dev/null +++ b/packages/client/src/pages/emojis.vue @@ -0,0 +1,36 @@ +<template> +<div :class="$style.root"> + <XCategory v-if="tab === 'category'"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, computed } from 'vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import XCategory from './emojis.category.vue'; + +export default defineComponent({ + components: { + XCategory, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.customEmojis, + icon: 'fas fa-laugh', + bg: 'var(--bg)', + })), + tab: 'category', + } + }, +}); +</script> + +<style lang="scss" module> +.root { + max-width: 1000px; + margin: 0 auto; +} +</style> diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue new file mode 100644 index 0000000000..7b1fcd0910 --- /dev/null +++ b/packages/client/src/pages/explore.vue @@ -0,0 +1,261 @@ +<template> +<div> + <MkSpacer :content-max="1200"> + <div class="lznhrdub"> + <div v-if="tab === 'local'"> + <div class="localfedi7 _block _isolated" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"> + <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header> + <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div> + </div> + + <template v-if="tag == null"> + <MkFolder class="_gap" persist-key="explore-pinned-users"> + <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template> + <XUserList :pagination="pinnedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-popular-users"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> + <XUserList :pagination="popularUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-updated-users"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-registered-users"> + <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsers"/> + </MkFolder> + </template> + </div> + <div v-else-if="tab === 'remote'"> + <div class="localfedi7 _block _isolated" v-if="tag == null" :style="{ backgroundImage: `url(/client-assets/fedi.jpg)` }"> + <header><span>{{ $ts.exploreFediverse }}</span></header> + </div> + + <MkFolder :foldable="true" :expanded="false" ref="tags" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template> + + <div class="vxjfqztj"> + <MkA v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</MkA> + </div> + </MkFolder> + + <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> + <XUserList :pagination="tagUsers"/> + </MkFolder> + + <template v-if="tag == null"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> + <XUserList :pagination="popularUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsersF"/> + </MkFolder> + </template> + </div> + <div v-else-if="tab === 'search'"> + <div class="_isolated"> + <MkInput v-model="searchQuery" :debounce="true" type="search"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.searchUser }}</template> + </MkInput> + <MkRadios v-model="searchOrigin"> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + <option value="both">{{ $ts.all }}</option> + </MkRadios> + </div> + + <XUserList v-if="searchQuery" class="_gap" :pagination="searchPagination" ref="search"/> + </div> + </div> + </MkSpacer> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XUserList from '@/components/user-list.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkInput from '@/components/form/input.vue'; +import MkRadios from '@/components/form/radios.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XUserList, + MkFolder, + MkInput, + MkRadios, + }, + + props: { + tag: { + type: String, + required: false + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.explore, + icon: 'fas fa-hashtag', + bg: 'var(--bg)', + tabs: [{ + active: this.tab === 'local', + title: this.$ts.local, + onClick: () => { this.tab = 'local'; }, + }, { + active: this.tab === 'remote', + title: this.$ts.remote, + onClick: () => { this.tab = 'remote'; }, + }, { + active: this.tab === 'search', + title: this.$ts.search, + onClick: () => { this.tab = 'search'; }, + },] + })), + tab: 'local', + pinnedUsers: { endpoint: 'pinned-users' }, + popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', + } }, + recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + sort: '+updatedAt', + } }, + recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + state: 'alive', + sort: '+createdAt', + } }, + popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'remote', + sort: '+follower', + } }, + recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+updatedAt', + } }, + recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+createdAt', + } }, + searchPagination: { + endpoint: 'users/search', + limit: 10, + params: computed(() => (this.searchQuery && this.searchQuery !== '') ? { + query: this.searchQuery, + origin: this.searchOrigin, + } : null) + }, + tagsLocal: [], + tagsRemote: [], + stats: null, + searchQuery: null, + searchOrigin: 'combined', + num: number, + }; + }, + + computed: { + meta() { + return this.$instance; + }, + tagUsers(): any { + return { + endpoint: 'hashtags/users', + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + } + }; + }, + }, + + watch: { + tag() { + if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); + }, + }, + + created() { + os.api('hashtags/list', { + sort: '+attachedLocalUsers', + attachedToLocalUserOnly: true, + limit: 30 + }).then(tags => { + this.tagsLocal = tags; + }); + os.api('hashtags/list', { + sort: '+attachedRemoteUsers', + attachedToRemoteUserOnly: true, + limit: 30 + }).then(tags => { + this.tagsRemote = tags; + }); + os.api('stats').then(stats => { + this.stats = stats; + }); + }, +}); +</script> + +<style lang="scss" scoped> +.localfedi7 { + color: #fff; + padding: 16px; + height: 80px; + background-position: 50%; + background-size: cover; + margin-bottom: var(--margin); + + > * { + &:not(:last-child) { + margin-bottom: 8px; + } + + > span { + display: inline-block; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.7); + } + } + + > header { + font-size: 20px; + font-weight: bold; + } + + > div { + font-size: 14px; + opacity: 0.8; + } +} + +.vxjfqztj { + > * { + margin-right: 16px; + + &.local { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue new file mode 100644 index 0000000000..980d59835f --- /dev/null +++ b/packages/client/src/pages/favorites.vue @@ -0,0 +1,60 @@ +<template> +<div class="jmelgwjh"> + <div class="body"> + <XNotes class="notes" :pagination="pagination" :detail="true" :prop="'note'" @before="before()" @after="after()"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.favorites, + icon: 'fas fa-star', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/favorites', + limit: 10, + params: () => ({ + }) + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> + +<style lang="scss" scoped> +.jmelgwjh { + background: var(--bg); + + > .body { + box-sizing: border-box; + max-width: 800px; + margin: 0 auto; + padding: 16px; + } +} +</style> diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue new file mode 100644 index 0000000000..f5edf25594 --- /dev/null +++ b/packages/client/src/pages/featured.vue @@ -0,0 +1,43 @@ +<template> +<MkSpacer :content-max="800"> + <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.featured, + icon: 'fas fa-fire-alt', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'notes/featured', + limit: 10, + offsetMode: true, + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue new file mode 100644 index 0000000000..1bd5da58e3 --- /dev/null +++ b/packages/client/src/pages/federation.vue @@ -0,0 +1,265 @@ +<template> +<div class="taeiyria"> + <div class="query"> + <MkInput v-model="host" :debounce="true" class=""> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.host }}</template> + </MkInput> + <div class="_inputSplit"> + <MkSelect v-model="state"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="federating">{{ $ts.federating }}</option> + <option value="subscribing">{{ $ts.subscribing }}</option> + <option value="publishing">{{ $ts.publishing }}</option> + <option value="suspended">{{ $ts.suspended }}</option> + <option value="blocked">{{ $ts.blocked }}</option> + <option value="notResponding">{{ $ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model="sort"> + <template #label>{{ $ts.sort }}</template> + <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> + <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> + <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> + <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> + <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> + <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> + <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> + <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> + <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> + <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> + <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option> + <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option> + <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option> + <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option> + <option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option> + <option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option> + <option value="+driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.descendingOrder }})</option> + <option value="-driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.ascendingOrder }})</option> + </MkSelect> + </div> + </div> + + <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state"> + <div class="dqokceoi"> + <MkA class="instance" v-for="instance in items" :key="instance.id" :to="`/instance-info/${instance.host}`"> + <div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div> + <div class="table"> + <div class="cell"> + <div class="key">{{ $ts.registeredAt }}</div> + <div class="value"><MkTime :time="instance.caughtAt"/></div> + </div> + <div class="cell"> + <div class="key">{{ $ts.software }}</div> + <div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.version }}</div> + <div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.users }}</div> + <div class="value">{{ instance.usersCount }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.notes }}</div> + <div class="value">{{ instance.notesCount }}</div> + </div> + <div class="cell"> + <div class="key">{{ $ts.sent }}</div> + <div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> + </div> + <div class="cell"> + <div class="key">{{ $ts.received }}</div> + <div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> + </div> + </div> + <div class="footer"> + <span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span> + <span class="pubSub"> + <span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span> + <span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span> + <span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span> + <span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span> + </span> + <span class="right"> + <span class="latestStatus">{{ instance.latestStatus || '-' }}</span> + <span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span> + </span> + </div> + </MkA> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSelect, + MkPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.federation, + icon: 'fas fa-globe', + bg: 'var(--bg)', + }, + host: '', + state: 'federating', + sort: '+pubSub', + pagination: { + endpoint: 'federation/instances', + limit: 10, + offsetMode: true, + params: () => ({ + sort: this.sort, + host: this.host != '' ? this.host : null, + ...( + this.state === 'federating' ? { federating: true } : + this.state === 'subscribing' ? { subscribing: true } : + this.state === 'publishing' ? { publishing: true } : + this.state === 'suspended' ? { suspended: true } : + this.state === 'blocked' ? { blocked: true } : + this.state === 'notResponding' ? { notResponding: true } : + {}) + }) + }, + } + }, + + watch: { + host() { + this.$refs.instances.reload(); + }, + state() { + this.$refs.instances.reload(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + getStatus(instance) { + if (instance.isSuspended) return 'suspended'; + if (instance.isNotResponding) return 'error'; + return 'alive'; + }, + } +}); +</script> + +<style lang="scss" scoped> +.taeiyria { + > .query { + background: var(--bg); + padding: 16px; + } +} + +.dqokceoi { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + padding: 16px; + + > .instance { + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .host { + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > img { + width: 18px; + height: 18px; + margin-right: 6px; + vertical-align: middle; + } + } + + > .table { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); + grid-gap: 6px; + margin: 6px 0; + font-size: 70%; + + > .cell { + > .key, > .value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .key { + opacity: 0.7; + } + + > .value { + } + } + } + + > .footer { + display: flex; + align-items: center; + font-size: 0.9em; + + > .status { + &.suspended { + opacity: 0.5; + } + + &.error { + color: var(--error); + } + + &.alive { + color: var(--success); + } + } + + > .pubSub { + margin-left: 8px; + } + + > .right { + margin-left: auto; + + > .latestStatus { + border: solid 1px var(--divider); + border-radius: 4px; + margin: 0 8px; + padding: 0 4px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue new file mode 100644 index 0000000000..d8967dc9d9 --- /dev/null +++ b/packages/client/src/pages/follow-requests.vue @@ -0,0 +1,153 @@ +<template> +<div> + <MkPagination :pagination="pagination" class="mk-follow-requests" ref="list"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="user _panel" v-for="req in items" :key="req.id"> + <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> + <div class="body"> + <div class="name"> + <MkA class="name" :to="userPage(req.follower)" v-user-preview="req.follower.id"><MkUserName :user="req.follower"/></MkA> + <p class="acct">@{{ acct(req.follower) }}</p> + </div> + <div class="description" v-if="req.follower.description" :title="req.follower.description"> + <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> + </div> + <div class="actions"> + <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> + <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> + </div> + </div> + </div> + </template> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import { userPage, acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.followRequests, + icon: 'fas fa-user-clock', + }, + pagination: { + endpoint: 'following/requests/list', + limit: 10, + }, + }; + }, + + methods: { + accept(user) { + os.api('following/requests/accept', { userId: user.id }).then(() => { + this.$refs.list.reload(); + }); + }, + reject(user) { + os.api('following/requests/reject', { userId: user.id }).then(() => { + this.$refs.list.reload(); + }); + }, + userPage, + acct + } +}); +</script> + +<style lang="scss" scoped> +.mk-follow-requests { + > .user { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 42px; + height: 42px; + border-radius: 8px; + } + + > .body { + display: flex; + width: calc(100% - 54px); + position: relative; + + > .name { + width: 45%; + + @media (max-width: 500px) { + width: 100%; + } + + > .name, + > .acct { + display: block; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin: 0; + } + + > .name { + font-size: 16px; + line-height: 24px; + } + + > .acct { + font-size: 15px; + line-height: 16px; + opacity: 0.7; + } + } + + > .description { + width: 55%; + line-height: 42px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + font-size: 14px; + padding-right: 40px; + padding-left: 8px; + box-sizing: border-box; + + @media (max-width: 500px) { + display: none; + } + } + + > .actions { + position: absolute; + top: 0; + bottom: 0; + right: 0; + margin: auto 0; + + > button { + padding: 12px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue new file mode 100644 index 0000000000..e8eaad73bf --- /dev/null +++ b/packages/client/src/pages/follow.vue @@ -0,0 +1,65 @@ +<template> +<div class="mk-follow-page"> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import * as Acct from 'misskey-js/built/acct'; + +export default defineComponent({ + created() { + const acct = new URL(location.href).searchParams.get('acct'); + if (acct == null) return; + + let promise; + + if (acct.startsWith('https://')) { + promise = os.api('ap/show', { + uri: acct + }); + promise.then(res => { + if (res.type == 'User') { + this.follow(res.object); + } else if (res.type === 'Note') { + this.$router.push(`/notes/${res.object.id}`); + } else { + os.dialog({ + type: 'error', + text: 'Not a user' + }).then(() => { + window.close(); + }); + } + }); + } else { + promise = os.api('users/show', Acct.parse(acct)); + promise.then(user => { + this.follow(user); + }); + } + + os.promiseDialog(promise, null, null, this.$ts.fetchingAsApObject); + }, + + methods: { + async follow(user) { + const { canceled } = await os.dialog({ + type: 'question', + text: this.$t('followConfirm', { name: user.name || user.username }), + showCancelButton: true + }); + + if (canceled) { + window.close(); + return; + } + + os.apiWithDialog('following/create', { + userId: user.id + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue new file mode 100644 index 0000000000..1ee3a9390b --- /dev/null +++ b/packages/client/src/pages/gallery/edit.vue @@ -0,0 +1,168 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormInput v-model="title"> + <span>{{ $ts.title }}</span> + </FormInput> + + <FormTextarea v-model="description" :max="500"> + <span>{{ $ts.description }}</span> + </FormTextarea> + + <FormGroup> + <div v-for="file in files" :key="file.id" class="_debobigegoItem _debobigegoPanel wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button class="remove _button" @click="remove(file)" v-tooltip="$ts.remove"><i class="fas fa-times"></i></button> + </div> + <FormButton @click="selectFile" primary><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> + </FormGroup> + + <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> + + <FormButton v-if="postId" @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton v-else @click="save" primary><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> + + <FormButton v-if="postId" @click="del" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormTuple from '@/components/debobigego/tuple.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormBase, + FormGroup, + FormSuspense, + }, + + props: { + postId: { + type: String, + required: false, + default: null, + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.postId ? { + title: this.$ts.edit, + icon: 'fas fa-pencil-alt' + } : { + title: this.$ts.postToGallery, + icon: 'fas fa-pencil-alt' + }), + init: null, + files: [], + description: null, + title: null, + isSensitive: false, + } + }, + + watch: { + postId: { + handler() { + this.init = () => this.postId ? os.api('gallery/posts/show', { + postId: this.postId + }).then(post => { + this.files = post.files; + this.title = post.title; + this.description = post.description; + this.isSensitive = post.isSensitive; + }) : Promise.resolve(null); + }, + immediate: true, + } + }, + + methods: { + selectFile(e) { + selectFile(e.currentTarget || e.target, null, true).then(files => { + this.files = this.files.concat(files); + }); + }, + + remove(file) { + this.files = this.files.filter(f => f.id !== file.id); + }, + + async save() { + if (this.postId) { + await os.apiWithDialog('gallery/posts/update', { + postId: this.postId, + title: this.title, + description: this.description, + fileIds: this.files.map(file => file.id), + isSensitive: this.isSensitive, + }); + this.$router.push(`/gallery/${this.postId}`); + } else { + const post = await os.apiWithDialog('gallery/posts/create', { + title: this.title, + description: this.description, + fileIds: this.files.map(file => file.id), + isSensitive: this.isSensitive, + }); + this.$router.push(`/gallery/${post.id}`); + } + }, + + async del() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.deleteConfirm, + showCancelButton: true + }); + if (canceled) return; + await os.apiWithDialog('gallery/posts/delete', { + postId: this.postId, + }); + this.$router.push(`/gallery`); + } + } +}); +</script> + +<style lang="scss" scoped> +.wqugxsfx { + height: 200px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; + position: relative; + + > .name { + position: absolute; + top: 8px; + left: 9px; + padding: 8px; + background: var(--panel); + } + + > .remove { + position: absolute; + top: 8px; + right: 9px; + padding: 8px; + background: var(--panel); + } +} +</style> diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue new file mode 100644 index 0000000000..dfcd59349e --- /dev/null +++ b/packages/client/src/pages/gallery/index.vue @@ -0,0 +1,152 @@ +<template> +<div class="xprsixdl _root"> + <MkTab v-model="tab" v-if="$i"> + <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option> + <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option> + <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option> + </MkTab> + + <div v-if="tab === 'explore'"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> + <MkPagination :pagination="recentPostsPagination" #default="{items}" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> + <MkPagination :pagination="popularPostsPagination" #default="{items}" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkFolder> + </div> + <div v-else-if="tab === 'liked'"> + <MkPagination :pagination="likedPostsPagination" #default="{items}"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="like in items" :post="like.post" :key="like.id" class="post"/> + </div> + </MkPagination> + </div> + <div v-else-if="tab === 'my'"> + <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> + <MkPagination :pagination="myPostsPagination" #default="{items}"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XUserList from '@/components/user-list.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkInput from '@/components/form/input.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XUserList, + MkFolder, + MkInput, + MkButton, + MkTab, + MkPagination, + MkGalleryPostPreview, + }, + + props: { + tag: { + type: String, + required: false + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.gallery, + icon: 'fas fa-icons' + }, + tab: 'explore', + recentPostsPagination: { + endpoint: 'gallery/posts', + limit: 6, + }, + popularPostsPagination: { + endpoint: 'gallery/featured', + limit: 5, + }, + myPostsPagination: { + endpoint: 'i/gallery/posts', + limit: 5, + }, + likedPostsPagination: { + endpoint: 'i/gallery/likes', + limit: 5, + }, + tags: [], + }; + }, + + computed: { + meta() { + return this.$instance; + }, + tagUsers(): any { + return { + endpoint: 'hashtags/users', + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', + } + }; + }, + }, + + watch: { + tag() { + if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); + }, + }, + + created() { + + }, + + methods: { + + } +}); +</script> + +<style lang="scss" scoped> +.xprsixdl { + max-width: 1400px; + margin: 0 auto; +} + +.vfpdbgtk { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: 0 var(--margin); + + > .post { + + } +} +</style> diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue new file mode 100644 index 0000000000..255954def0 --- /dev/null +++ b/packages/client/src/pages/gallery/post.vue @@ -0,0 +1,282 @@ +<template> +<div class="_root"> + <transition name="fade" mode="out-in"> + <div v-if="post" class="rkxwuolj"> + <div class="files"> + <div class="file" v-for="file in post.files" :key="file.id"> + <img :src="file.url"/> + </div> + </div> + <div class="body _block"> + <div class="title">{{ post.title }}</div> + <div class="description"><Mfm :text="post.description"/></div> + <div class="info"> + <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton class="button" @click="unlike()" v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton> + <MkButton class="button" @click="like()" v-else v-tooltip="$ts._gallery.like"><i class="far fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-if="$i && $i.id === post.user.id" class="_button" @click="edit" v-tooltip="$ts.edit" v-click-anime><i class="fas fa-pencil-alt fa-fw"></i></button> + <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button> + <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="post.user" class="avatar"/> + <div class="name"> + <MkUserName :user="post.user" style="display: block;"/> + <MkAcct :user="post.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <MkPagination :pagination="otherPostsPagination" #default="{items}"> + <div class="sdrarzaf"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import MkContainer from '@/components/ui/container.vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; +import MkFollowButton from '@/components/follow-button.vue'; +import { url } from '@/config'; + +export default defineComponent({ + components: { + MkContainer, + ImgWithBlurhash, + MkPagination, + MkGalleryPostPreview, + MkButton, + MkFollowButton, + }, + props: { + postId: { + type: String, + required: true + } + }, + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.post ? { + title: this.post.title, + avatar: this.post.user, + path: `/gallery/${this.post.id}`, + share: { + title: this.post.title, + text: this.post.description, + }, + actions: [{ + icon: 'fas fa-pencil-alt', + text: this.$ts.edit, + handler: this.edit + }] + } : null), + otherPostsPagination: { + endpoint: 'users/gallery/posts', + limit: 6, + params: () => ({ + userId: this.post.user.id + }) + }, + post: null, + error: null, + }; + }, + + watch: { + postId: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.post = null; + os.api('gallery/posts/show', { + postId: this.postId + }).then(post => { + this.post = post; + }).catch(e => { + this.error = e; + }); + }, + + share() { + navigator.share({ + title: this.post.title, + text: this.post.description, + url: `${url}/gallery/${this.post.id}` + }); + }, + + shareWithNote() { + os.post({ + initialText: `${this.post.title} ${url}/gallery/${this.post.id}` + }); + }, + + like() { + os.apiWithDialog('gallery/posts/like', { + postId: this.postId, + }).then(() => { + this.post.isLiked = true; + this.post.likedCount++; + }); + }, + + async unlike() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('gallery/posts/unlike', { + postId: this.postId, + }).then(() => { + this.post.isLiked = false; + this.post.likedCount--; + }); + }, + + edit() { + this.$router.push(`/gallery/${this.post.id}/edit`); + } + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.rkxwuolj { + > .files { + > .file { + > img { + display: block; + max-width: 100%; + max-height: 500px; + margin: 0 auto; + } + + & + .file { + margin-top: 16px; + } + } + } + + > .body { + padding: 32px; + + > .title { + font-weight: bold; + font-size: 1.2em; + margin-bottom: 16px; + } + + > .info { + margin-top: 16px; + font-size: 90%; + opacity: 0.7; + } + + > .actions { + display: flex; + align-items: center; + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .like { + > .button { + --accent: rgb(241 97 132); + --X8: rgb(241 92 128); + --buttonBg: rgb(216 71 106 / 5%); + --buttonHoverBg: rgb(216 71 106 / 10%); + color: #ff002f; + + ::v-deep(.count) { + margin-left: 0.5em; + } + } + } + + > .other { + margin-left: auto; + + > button { + padding: 8px; + margin: 0 8px; + + &:hover { + color: var(--fgHighlighted); + } + } + } + } + + > .user { + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + display: flex; + align-items: center; + + > .avatar { + width: 52px; + height: 52px; + } + + > .name { + margin: 0 0 0 12px; + font-size: 90%; + } + + > .koudoku { + margin-left: auto; + } + } + } +} + +.sdrarzaf { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: var(--margin); + + > .post { + + } +} +</style> diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue new file mode 100644 index 0000000000..586d9d7e52 --- /dev/null +++ b/packages/client/src/pages/instance-info.vue @@ -0,0 +1,238 @@ +<template> +<FormBase> + <FormGroup v-if="instance"> + <template #label>{{ instance.host }}</template> + <FormGroup> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel fnfelxur"> + <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/> + </div> + </div> + <FormKeyValueView> + <template #key>Name</template> + <template #value><span class="_monospace">{{ instance.name || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + + <FormButton v-if="$i.isAdmin || $i.isModerator" @click="info" primary>{{ $ts.settings }}</FormButton> + + <FormTextarea readonly :value="instance.description"> + <span>{{ $ts.description }}</span> + </FormTextarea> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.version }}</template> + <template #value><span class="_monospace">{{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.administrator }}</template> + <template #value><span class="_monospace">{{ instance.maintainerName || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.contact }}</template> + <template #value><span class="_monospace">{{ instance.maintainerEmail || `(${$ts.unknown})` }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.latestRequestSentAt }}</template> + <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestStatus }}</template> + <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestRequestReceivedAt }}</template> + <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <FormKeyValueView> + <template #key>Open Registrations</template> + <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + </FormGroup> + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.statistics }}</div> + <div class="_debobigegoPanel cmhjzshl"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option> + <option value="instance-users">{{ $ts._instanceCharts.users }}</option> + <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option> + <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option> + <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option> + <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option> + <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option> + <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option> + <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option> + <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> + <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart> + </div> + </div> + </div> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.registeredAt }}</template> + <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> + </FormKeyValueView> + </FormGroup> + <FormObjectView tall :value="instance"> + <span>Raw</span> + </FormObjectView> + <FormGroup> + <template #label>Well-known resources</template> + <FormLink :to="`https://${host}/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`https://${host}/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`https://${host}/.well-known/nodeinfo`" external>nodeinfo</FormLink> + <FormLink :to="`https://${host}/robots.txt`" external>robots.txt</FormLink> + <FormLink :to="`https://${host}/manifest.json`" external>manifest.json</FormLink> + </FormGroup> + <FormSuspense :p="dnsPromiseFactory" v-slot="{ result: dns }"> + <FormGroup> + <template #label>DNS</template> + <FormKeyValueView v-for="record in dns.a" :key="record"> + <template #key>A</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.aaaa" :key="record"> + <template #key>AAAA</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.cname" :key="record"> + <template #key>CNAME</template> + <template #value><span class="_monospace">{{ record }}</span></template> + </FormKeyValueView> + <FormKeyValueView v-for="record in dns.txt"> + <template #key>TXT</template> + <template #value><span class="_monospace">{{ record[0] }}</span></template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import MkChart from '@/components/chart.vue'; +import FormObjectView from '@/components/debobigego/object-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import MkSelect from '@/components/form/select.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; +import MkInstanceInfo from '@/pages/admin/instance.vue'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + MkSelect, + MkChart, + }, + + props: { + host: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.instanceInfo, + icon: 'fas fa-info-circle', + actions: [{ + text: `https://${this.host}`, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(`https://${this.host}`, '_blank'); + } + }], + }, + instance: null, + dnsPromiseFactory: () => os.api('federation/dns', { + host: this.host + }), + chartSrc: 'instance-requests', + chartSpan: 'hour', + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + number, + bytes, + + async fetch() { + this.instance = await os.api('federation/show-instance', { + host: this.host + }); + }, + + info() { + os.popup(MkInstanceInfo, { + instance: this.instance + }, {}, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.fnfelxur { + padding: 16px; + + > .icon { + display: block; + margin: auto; + height: 64px; + border-radius: 8px; + } +} + +.cmhjzshl { + > .selects { + display: flex; + padding: 16px; + } +} +</style> diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue new file mode 100644 index 0000000000..cd9c6a8fdf --- /dev/null +++ b/packages/client/src/pages/mentions.vue @@ -0,0 +1,42 @@ +<template> +<MkSpacer :content-max="800"> + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.mentions, + icon: 'fas fa-at', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'notes/mentions', + limit: 10, + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue new file mode 100644 index 0000000000..9fde0bc7d5 --- /dev/null +++ b/packages/client/src/pages/messages.vue @@ -0,0 +1,45 @@ +<template> +<MkSpacer :content-max="800"> + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.directNotes, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'notes/mentions', + limit: 10, + params: () => ({ + visibility: 'specified' + }) + }, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue new file mode 100644 index 0000000000..896c3927ce --- /dev/null +++ b/packages/client/src/pages/messaging/index.vue @@ -0,0 +1,307 @@ +<template> +<MkSpacer :content-max="800"> + <div class="yweeujhr" v-size="{ max: [400] }"> + <MkButton @click="start" primary class="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton> + + <div class="history" v-if="messages.length > 0"> + <MkA v-for="(message, i) in messages" + class="message _block" + :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }" + :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-index="i" + :key="message.id" + v-anim="i" + > + <div> + <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/> + <header v-if="message.groupId"> + <span class="name">{{ message.group.name }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <header v-else> + <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> + <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <div class="body"> + <p class="text"><span class="me" v-if="isMe(message)">{{ $ts.you }}:</span>{{ message.text }}</p> + </div> + </div> + </MkA> + </div> + <div class="_fullinfo" v-if="!fetching && messages.length == 0"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkButton from '@/components/ui/button.vue'; +import { acct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.messaging, + icon: 'fas fa-comments', + bg: 'var(--bg)', + }, + fetching: true, + moreFetching: false, + messages: [], + connection: null, + }; + }, + + mounted() { + this.connection = markRaw(os.stream.useChannel('messagingIndex')); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + + os.api('messaging/history', { group: false }).then(userMessages => { + os.api('messaging/history', { group: true }).then(groupMessages => { + const messages = userMessages.concat(groupMessages); + messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + this.messages = messages; + this.fetching = false; + }); + }); + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + getAcct: Acct.toString, + + isMe(message) { + return message.userId == this.$i.id; + }, + + onMessage(message) { + if (message.recipientId) { + this.messages = this.messages.filter(m => !( + (m.recipientId == message.recipientId && m.userId == message.userId) || + (m.recipientId == message.userId && m.userId == message.recipientId))); + + this.messages.unshift(message); + } else if (message.groupId) { + this.messages = this.messages.filter(m => m.groupId !== message.groupId); + this.messages.unshift(message); + } + }, + + onRead(ids) { + for (const id of ids) { + const found = this.messages.find(m => m.id == id); + if (found) { + if (found.recipientId) { + found.isRead = true; + } else if (found.groupId) { + found.reads.push(this.$i.id); + } + } + } + }, + + start(ev) { + os.popupMenu([{ + text: this.$ts.messagingWithUser, + icon: 'fas fa-user', + action: () => { this.startUser() } + }, { + text: this.$ts.messagingWithGroup, + icon: 'fas fa-users', + action: () => { this.startGroup() } + }], ev.currentTarget || ev.target); + }, + + async startUser() { + os.selectUser().then(user => { + this.$router.push(`/my/messaging/${Acct.toString(user)}`); + }); + }, + + async startGroup() { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + if (groups1.length === 0 && groups2.length === 0) { + os.dialog({ + type: 'warning', + title: this.$ts.youHaveNoGroups, + text: this.$ts.joinOrCreateGroup, + }); + return; + } + const { canceled, result: group } = await os.dialog({ + type: null, + title: this.$ts.group, + select: { + items: groups1.concat(groups2).map(group => ({ + value: group, text: group.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + this.$router.push(`/my/messaging/group/${group.id}`); + }, + + acct + } +}); +</script> + +<style lang="scss" scoped> +.yweeujhr { + + > .start { + margin: 0 auto var(--margin) auto; + } + + > .history { + > .message { + display: block; + text-decoration: none; + margin-bottom: var(--margin); + + * { + pointer-events: none; + user-select: none; + } + + &:hover { + .avatar { + filter: saturate(200%); + } + } + + &:active { + } + + &.isRead, + &.isMe { + opacity: 0.8; + } + + &:not(.isMe):not(.isRead) { + > div { + background-image: url("/client-assets/unread.svg"); + background-repeat: no-repeat; + background-position: 0 center; + } + } + + &:after { + content: ""; + display: block; + clear: both; + } + + > div { + padding: 20px 30px; + + &:after { + content: ""; + display: block; + clear: both; + } + + > header { + display: flex; + align-items: center; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + + > .name { + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 1em; + font-weight: bold; + transition: all 0.1s ease; + } + + > .username { + margin: 0 8px; + } + + > .time { + margin: 0 0 0 auto; + } + } + + > .avatar { + float: left; + width: 54px; + height: 54px; + margin: 0 16px 0 0; + border-radius: 8px; + transition: all 0.1s ease; + } + + > .body { + + > .text { + display: block; + margin: 0 0 0 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1.1em; + color: var(--faceText); + + .me { + opacity: 0.7; + } + } + + > .image { + display: block; + max-width: 100%; + max-height: 512px; + } + } + } + } + } + + &.max-width_400px { + > .history { + > .message { + &:not(.isMe):not(.isRead) { + > div { + background-image: none; + border-left: solid 4px #3aa2dc; + } + } + + > div { + padding: 16px; + font-size: 0.9em; + + > .avatar { + margin: 0 12px 0 0; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue new file mode 100644 index 0000000000..aafed2632d --- /dev/null +++ b/packages/client/src/pages/messaging/messaging-room.form.vue @@ -0,0 +1,348 @@ +<template> +<div class="pemppnzi _block" + @dragover.stop="onDragover" + @drop.stop="onDrop" +> + <textarea + v-model="text" + ref="text" + @keypress="onKeypress" + @compositionupdate="onCompositionUpdate" + @paste="onPaste" + :placeholder="$ts.inputMessageHere" + ></textarea> + <div class="file" @click="file = null" v-if="file">{{ file.name }}</div> + <button class="send _button" @click="send" :disabled="!canSend || sending" :title="$ts.send"> + <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> + </button> + <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> + <input ref="file" type="file" @change="onChangeFile"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import * as autosize from 'autosize'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { Autocomplete } from '@/scripts/autocomplete'; +import { throttle } from 'throttle-debounce'; + +export default defineComponent({ + props: { + user: { + type: Object, + requird: false, + }, + group: { + type: Object, + requird: false, + }, + }, + data() { + return { + text: null, + file: null, + sending: false, + typing: throttle(3000, () => { + os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id }); + }), + }; + }, + computed: { + draftKey(): string { + return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; + }, + canSend(): boolean { + return (this.text != null && this.text != '') || this.file != null; + }, + room(): any { + return this.$parent; + } + }, + watch: { + text() { + this.saveDraft(); + }, + file() { + this.saveDraft(); + } + }, + mounted() { + autosize(this.$refs.text); + + // TODO: detach when unmount + new Autocomplete(this.$refs.text, this, { model: 'text' }); + + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; + if (draft) { + this.text = draft.data.text; + this.file = draft.data.file; + } + }, + methods: { + async onPaste(e: ClipboardEvent) { + const data = e.clipboardData; + const items = data.items; + + if (items.length == 1) { + if (items[0].kind == 'file') { + const file = items[0].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, '1')}${ext}`; + const name = this.$store.state.pasteDialog + ? await os.dialog({ + title: this.$ts.enterFileName, + input: { + default: formatted + }, + allowEmpty: false + }).then(({ canceled, result }) => canceled ? false : result) + : formatted; + if (name) this.upload(file, name); + } + } else { + if (items[0].kind == 'file') { + os.dialog({ + type: 'error', + text: this.$ts.onlyOneFileCanBeAttached + }); + } + } + }, + + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + e.preventDefault(); + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + e.preventDefault(); + this.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + e.preventDefault(); + os.dialog({ + type: 'error', + text: this.$ts.onlyOneFileCanBeAttached + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + this.file = JSON.parse(driveFile); + e.preventDefault(); + } + //#endregion + }, + + onKeypress(e) { + this.typing(); + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) { + this.send(); + } + }, + + onCompositionUpdate() { + this.typing(); + }, + + chooseFile(e) { + selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => { + this.file = file; + }); + }, + + onChangeFile() { + this.upload((this.$refs.file as any).files[0]); + }, + + upload(file: File, name?: string) { + os.upload(file, this.$store.state.uploadFolder, name).then(res => { + this.file = res; + }); + }, + + send() { + this.sending = true; + os.api('messaging/messages/create', { + userId: this.user ? this.user.id : undefined, + groupId: this.group ? this.group.id : undefined, + text: this.text ? this.text : undefined, + fileId: this.file ? this.file.id : undefined + }).then(message => { + this.clear(); + }).catch(err => { + console.error(err); + }).then(() => { + this.sending = false; + }); + }, + + clear() { + this.text = ''; + this.file = null; + this.deleteDraft(); + }, + + saveDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + data[this.draftKey] = { + updatedAt: new Date(), + data: { + text: this.text, + file: this.file + } + } + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + + deleteDraft() { + const data = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + + delete data[this.draftKey]; + + localStorage.setItem('message_drafts', JSON.stringify(data)); + }, + + async insertEmoji(ev) { + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); + } + } +}); +</script> + +<style lang="scss" scoped> +.pemppnzi { + position: relative; + + > textarea { + cursor: auto; + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + height: 80px; + margin: 0; + padding: 16px 16px 0 16px; + resize: none; + font-size: 1em; + font-family: inherit; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + box-sizing: border-box; + color: var(--fg); + } + + > .file { + padding: 8px; + color: #444; + background: #eee; + cursor: pointer; + } + + > .send { + position: absolute; + bottom: 0; + right: 0; + margin: 0; + padding: 16px; + font-size: 1em; + transition: color 0.1s ease; + color: var(--accent); + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + + .files { + display: block; + margin: 0; + padding: 0 8px; + list-style: none; + + &:after { + content: ''; + display: block; + clear: both; + } + + > li { + display: block; + float: left; + margin: 4px; + padding: 0; + width: 64px; + height: 64px; + background-color: #eee; + background-repeat: no-repeat; + background-position: center center; + background-size: cover; + cursor: move; + + &:hover { + > .remove { + display: block; + } + } + + > .remove { + display: none; + position: absolute; + right: -6px; + top: -6px; + margin: 0; + padding: 0; + background: transparent; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + cursor: pointer; + } + } + } + + ._button { + margin: 0; + padding: 16px; + font-size: 1em; + font-weight: normal; + text-decoration: none; + transition: color 0.1s ease; + + &:hover { + color: var(--accent); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } + } + + input[type=file] { + display: none; + } +} +</style> diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue new file mode 100644 index 0000000000..432d11add8 --- /dev/null +++ b/packages/client/src/pages/messaging/messaging-room.message.vue @@ -0,0 +1,350 @@ +<template> +<div class="thvuemwp" :class="{ isMe }" v-size="{ max: [400, 500] }"> + <MkAvatar class="avatar" :user="message.user" :show-indicator="true"/> + <div class="content"> + <div class="balloon" :class="{ noText: message.text == null }" > + <button class="delete-button" v-if="isMe" :title="$ts.delete" @click="del"> + <img src="/client-assets/remove.png" alt="Delete"/> + </button> + <div class="content" v-if="!message.isDeleted"> + <Mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$i"/> + <div class="file" v-if="message.file"> + <a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name"> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <p v-else>{{ message.file.name }}</p> + </a> + </div> + </div> + <div class="content" v-else> + <p class="is-deleted">{{ $ts.deleted }}</p> + </div> + </div> + <div></div> + <MkUrlPreview v-for="url in urls" :url="url" :key="url" style="margin: 8px 0;"/> + <footer> + <template v-if="isGroup"> + <span class="read" v-if="message.reads.length > 0">{{ $ts.messageRead }} {{ message.reads.length }}</span> + </template> + <template v-else> + <span class="read" v-if="isMe && message.isRead">{{ $ts.messageRead }}</span> + </template> + <MkTime :time="message.createdAt"/> + <template v-if="message.is_edited"><i class="fas fa-pencil-alt"></i></template> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as mfm from 'mfm-js'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; +import MkUrlPreview from '@/components/url-preview.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkUrlPreview + }, + props: { + message: { + required: true + }, + isGroup: { + required: false + } + }, + computed: { + isMe(): boolean { + return this.message.userId === this.$i.id; + }, + urls(): string[] { + if (this.message.text) { + return extractUrlFromMfm(mfm.parse(this.message.text)); + } else { + return []; + } + } + }, + methods: { + del() { + os.api('messaging/messages/delete', { + messageId: this.message.id + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.thvuemwp { + $me-balloon-color: var(--accent); + + position: relative; + background-color: transparent; + display: flex; + + > .avatar { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + display: block; + width: 54px; + height: 54px; + transition: all 0.1s ease; + } + + > .content { + min-width: 0; + + > .balloon { + position: relative; + display: inline-flex; + align-items: center; + padding: 0; + min-height: 38px; + border-radius: 16px; + max-width: 100%; + + &:before { + content: ""; + pointer-events: none; + display: block; + position: absolute; + top: 12px; + } + + & + * { + clear: both; + } + + &:hover { + > .delete-button { + display: block; + } + } + + > .delete-button { + display: none; + position: absolute; + z-index: 1; + top: -4px; + right: -4px; + margin: 0; + padding: 0; + cursor: pointer; + outline: none; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + + > img { + vertical-align: bottom; + width: 16px; + height: 16px; + cursor: pointer; + } + } + + > .content { + max-width: 100%; + + > .is-deleted { + display: block; + margin: 0; + padding: 0; + overflow: hidden; + overflow-wrap: break-word; + font-size: 1em; + color: rgba(#000, 0.5); + } + + > .text { + display: block; + margin: 0; + padding: 12px 18px; + overflow: hidden; + overflow-wrap: break-word; + word-break: break-word; + font-size: 1em; + color: rgba(#000, 0.8); + + & + .file { + > a { + border-radius: 0 0 16px 16px; + } + } + } + + > .file { + > a { + display: block; + max-width: 100%; + border-radius: 16px; + overflow: hidden; + text-decoration: none; + + &:hover { + text-decoration: none; + + > p { + background: #ccc; + } + } + + > * { + display: block; + margin: 0; + width: 100%; + max-height: 512px; + object-fit: contain; + box-sizing: border-box; + } + + > p { + padding: 30px; + text-align: center; + color: #555; + background: #ddd; + } + } + } + } + } + + > footer { + display: block; + margin: 2px 0 0 0; + font-size: 0.65em; + + > .read { + margin: 0 8px; + } + + > i { + margin-left: 4px; + } + } + } + + &:not(.isMe) { + padding-left: var(--margin); + + > .content { + padding-left: 16px; + padding-right: 32px; + + > .balloon { + $color: var(--messageBg); + background: $color; + + &.noText { + background: transparent; + } + + &:not(.noText):before { + left: -14px; + border-top: solid 8px transparent; + border-right: solid 8px $color; + border-bottom: solid 8px transparent; + border-left: solid 8px transparent; + } + + > .content { + > .text { + color: var(--fg); + } + } + } + + > footer { + text-align: left; + } + } + } + + &.isMe { + flex-direction: row-reverse; + padding-right: var(--margin); + + > .content { + padding-right: 16px; + padding-left: 32px; + text-align: right; + + > .balloon { + background: $me-balloon-color; + text-align: left; + + ::selection { + color: var(--accent); + background-color: #fff; + } + + &.noText { + background: transparent; + } + + &:not(.noText):before { + right: -14px; + left: auto; + border-top: solid 8px transparent; + border-right: solid 8px transparent; + border-bottom: solid 8px transparent; + border-left: solid 8px $me-balloon-color; + } + + > .content { + + > p.is-deleted { + color: rgba(#fff, 0.5); + } + + > .text { + &, ::v-deep(*) { + color: var(--fgOnAccent) !important; + } + } + } + } + + > footer { + text-align: right; + + > .read { + user-select: none; + } + } + } + } + + &.max-width_400px { + > .avatar { + width: 48px; + height: 48px; + } + + > .content { + > .balloon { + > .content { + > .text { + font-size: 0.9em; + } + } + } + } + } + + &.max-width_500px { + > .content { + > .balloon { + > .content { + > .text { + padding: 8px 16px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue new file mode 100644 index 0000000000..3a19b12762 --- /dev/null +++ b/packages/client/src/pages/messaging/messaging-room.vue @@ -0,0 +1,470 @@ +<template> +<div class="_section" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" +> + <div class="_content mk-messaging-room"> + <div class="body"> + <MkLoading v-if="fetching"/> + <p class="empty" v-if="!fetching && messages.length == 0"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p> + <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p> + <button class="more _button" ref="loadMore" :class="{ fetching: fetchingMoreMessages }" v-show="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages"> + <template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }} + </button> + <XList class="messages" :items="messages" v-slot="{ item: message }" direction="up" reversed> + <XMessage :message="message" :is-group="group != null" :key="message.id"/> + </XList> + </div> + <footer> + <div class="typers" v-if="typers.length > 0"> + <I18n :src="$ts.typingUsers" text-tag="span" class="users"> + <template #users> + <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> + </template> + </I18n> + <MkEllipsis/> + </div> + <transition name="fade"> + <div class="new-message" v-show="showIndicator"> + <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button> + </div> + </transition> + <XForm v-if="!fetching" :user="user" :group="group" ref="form" class="form"/> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw } from 'vue'; +import XList from '@/components/date-separated-list.vue'; +import XMessage from './messaging-room.message.vue'; +import XForm from './messaging-room.form.vue'; +import * as Acct from 'misskey-js/built/acct'; +import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import { popout } from '@/scripts/popout'; +import * as sound from '@/scripts/sound'; +import * as symbols from '@/symbols'; + +const Component = defineComponent({ + components: { + XMessage, + XForm, + XList, + }, + + inject: ['inWindow'], + + props: { + userAcct: { + type: String, + required: false, + }, + groupId: { + type: String, + required: false, + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? { + userName: this.user, + avatar: this.user, + action: { + icon: 'fas fa-ellipsis-h', + handler: this.menu, + }, + } : { + title: this.group.name, + icon: 'fas fa-users', + action: { + icon: 'fas fa-ellipsis-h', + handler: this.menu, + }, + } : null), + fetching: true, + user: null, + group: null, + fetchingMoreMessages: false, + messages: [], + existMoreMessages: false, + connection: null, + showIndicator: false, + timer: null, + typers: [], + ilObserver: new IntersectionObserver( + (entries) => entries.some((entry) => entry.isIntersecting) + && !this.fetching + && !this.fetchingMoreMessages + && this.existMoreMessages + && this.fetchMoreMessages() + ), + }; + }, + + computed: { + form(): any { + return this.$refs.form; + } + }, + + watch: { + userAcct: 'fetch', + groupId: 'fetch', + }, + + mounted() { + this.fetch(); + if (this.$store.state.enableInfiniteScroll) { + this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element)); + } + }, + + beforeUnmount() { + this.connection.dispose(); + + document.removeEventListener('visibilitychange', this.onVisibilitychange); + + this.ilObserver.disconnect(); + }, + + methods: { + async fetch() { + this.fetching = true; + if (this.userAcct) { + const user = await os.api('users/show', Acct.parse(this.userAcct)); + this.user = user; + } else { + const group = await os.api('users/groups/show', { groupId: this.groupId }); + this.group = group; + } + + this.connection = markRaw(os.stream.useChannel('messaging', { + otherparty: this.user ? this.user.id : undefined, + group: this.group ? this.group.id : undefined, + })); + + this.connection.on('message', this.onMessage); + this.connection.on('read', this.onRead); + this.connection.on('deleted', this.onDeleted); + this.connection.on('typers', typers => { + this.typers = typers.filter(u => u.id !== this.$i.id); + }); + + document.addEventListener('visibilitychange', this.onVisibilitychange); + + this.fetchMessages().then(() => { + this.scrollToBottom(); + + // もっと見るの交差検知を発火させないためにfetchは + // スクロールが終わるまでfalseにしておく + // scrollendのようなイベントはないのでsetTimeoutで + setTimeout(() => this.fetching = false, 300); + }); + }, + + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; + + if (isFile || isDriveFile) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDrop(e): void { + // ファイルだったら + if (e.dataTransfer.files.length == 1) { + this.form.upload(e.dataTransfer.files[0]); + return; + } else if (e.dataTransfer.files.length > 1) { + os.dialog({ + type: 'error', + text: this.$ts.onlyOneFileCanBeAttached + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.form.file = file; + } + //#endregion + }, + + fetchMessages() { + return new Promise((resolve, reject) => { + const max = this.existMoreMessages ? 20 : 10; + + os.api('messaging/messages', { + userId: this.user ? this.user.id : undefined, + groupId: this.group ? this.group.id : undefined, + limit: max + 1, + untilId: this.existMoreMessages ? this.messages[0].id : undefined + }).then(messages => { + if (messages.length == max + 1) { + this.existMoreMessages = true; + messages.pop(); + } else { + this.existMoreMessages = false; + } + + this.messages.unshift.apply(this.messages, messages.reverse()); + resolve(); + }); + }); + }, + + fetchMoreMessages() { + this.fetchingMoreMessages = true; + this.fetchMessages().then(() => { + this.fetchingMoreMessages = false; + }); + }, + + onMessage(message) { + sound.play('chat'); + + const _isBottom = isBottom(this.$el, 64); + + this.messages.push(message); + if (message.userId != this.$i.id && !document.hidden) { + this.connection.send('read', { + id: message.id + }); + } + + if (_isBottom) { + // Scroll to bottom + this.$nextTick(() => { + this.scrollToBottom(); + }); + } else if (message.userId != this.$i.id) { + // Notify + this.notifyNewMessage(); + } + }, + + onRead(x) { + if (this.user) { + if (!Array.isArray(x)) x = [x]; + for (const id of x) { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist] = { + ...this.messages[exist], + isRead: true, + }; + } + } + } else if (this.group) { + for (const id of x.ids) { + if (this.messages.some(x => x.id == id)) { + const exist = this.messages.map(x => x.id).indexOf(id); + this.messages[exist] = { + ...this.messages[exist], + reads: [...this.messages[exist].reads, x.userId] + }; + } + } + } + }, + + onDeleted(id) { + const msg = this.messages.find(m => m.id === id); + if (msg) { + this.messages = this.messages.filter(m => m.id !== msg.id); + } + }, + + scrollToBottom() { + scroll(this.$el, { top: this.$el.offsetHeight }); + }, + + onIndicatorClick() { + this.showIndicator = false; + this.scrollToBottom(); + }, + + notifyNewMessage() { + this.showIndicator = true; + + onScrollBottom(this.$el, () => { + this.showIndicator = false; + }); + + if (this.timer) clearTimeout(this.timer); + + this.timer = setTimeout(() => { + this.showIndicator = false; + }, 4000); + }, + + onVisibilitychange() { + if (document.hidden) return; + for (const message of this.messages) { + if (message.userId !== this.$i.id && !message.isRead) { + this.connection.send('read', { + id: message.id + }); + } + } + }, + + menu(ev) { + const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`; + + os.popupMenu([this.inWindow ? undefined : { + text: this.$ts.openInWindow, + icon: 'fas fa-window-maximize', + action: () => { + os.pageWindow(path); + this.$router.back(); + }, + }, this.inWindow ? undefined : { + text: this.$ts.popout, + icon: 'fas fa-external-link-alt', + action: () => { + popout(path); + this.$router.back(); + }, + }], ev.currentTarget || ev.target); + } + } +}); + +export default Component; +</script> + +<style lang="scss" scoped> +.mk-messaging-room { + > .body { + > .empty { + width: 100%; + margin: 0; + padding: 16px 8px 8px 8px; + text-align: center; + font-size: 0.8em; + opacity: 0.5; + + i { + margin-right: 4px; + } + } + + > .no-history { + display: block; + margin: 0; + padding: 16px; + text-align: center; + font-size: 0.8em; + color: var(--messagingRoomInfo); + opacity: 0.5; + + i { + margin-right: 4px; + } + } + + > .more { + display: block; + margin: 16px auto; + padding: 0 12px; + line-height: 24px; + color: #fff; + background: rgba(#000, 0.3); + border-radius: 12px; + + &:hover { + background: rgba(#000, 0.4); + } + + &:active { + background: rgba(#000, 0.5); + } + + &.fetching { + cursor: wait; + } + + > i { + margin-right: 4px; + } + } + + > .messages { + > ::v-deep(*) { + margin-bottom: 16px; + } + } + } + + > footer { + width: 100%; + position: relative; + + > .new-message { + position: absolute; + top: -48px; + width: 100%; + padding: 8px 0; + text-align: center; + + > button { + display: inline-block; + margin: 0; + padding: 0 12px 0 30px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; + + > i { + position: absolute; + top: 0; + left: 10px; + line-height: 32px; + font-size: 16px; + } + } + } + + > .typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + color: var(--fgTransparentWeak); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } + + > .form { + border-top: solid 0.5px var(--divider); + } + } +} + +.fade-enter-active, .fade-leave-active { + transition: opacity 0.1s; +} + +.fade-enter-from, .fade-leave-to { + transition: opacity 0.5s; + opacity: 0; +} +</style> diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue new file mode 100644 index 0000000000..e9a3b6debc --- /dev/null +++ b/packages/client/src/pages/mfm-cheat-sheet.vue @@ -0,0 +1,365 @@ +<template> +<div class="mwysmxbg"> + <div class="_isolated">{{ $ts._mfm.intro }}</div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.mention }}</div> + <div class="content"> + <p>{{ $ts._mfm.mentionDescription }}</p> + <div class="preview"> + <Mfm :text="preview_mention"/> + <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.hashtag }}</div> + <div class="content"> + <p>{{ $ts._mfm.hashtagDescription }}</p> + <div class="preview"> + <Mfm :text="preview_hashtag"/> + <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.url }}</div> + <div class="content"> + <p>{{ $ts._mfm.urlDescription }}</p> + <div class="preview"> + <Mfm :text="preview_url"/> + <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.link }}</div> + <div class="content"> + <p>{{ $ts._mfm.linkDescription }}</p> + <div class="preview"> + <Mfm :text="preview_link"/> + <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.emoji }}</div> + <div class="content"> + <p>{{ $ts._mfm.emojiDescription }}</p> + <div class="preview"> + <Mfm :text="preview_emoji"/> + <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.bold }}</div> + <div class="content"> + <p>{{ $ts._mfm.boldDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bold"/> + <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.small }}</div> + <div class="content"> + <p>{{ $ts._mfm.smallDescription }}</p> + <div class="preview"> + <Mfm :text="preview_small"/> + <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.quote }}</div> + <div class="content"> + <p>{{ $ts._mfm.quoteDescription }}</p> + <div class="preview"> + <Mfm :text="preview_quote"/> + <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.center }}</div> + <div class="content"> + <p>{{ $ts._mfm.centerDescription }}</p> + <div class="preview"> + <Mfm :text="preview_center"/> + <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.inlineCode }}</div> + <div class="content"> + <p>{{ $ts._mfm.inlineCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineCode"/> + <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.blockCode }}</div> + <div class="content"> + <p>{{ $ts._mfm.blockCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blockCode"/> + <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.inlineMath }}</div> + <div class="content"> + <p>{{ $ts._mfm.inlineMathDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineMath"/> + <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.search }}</div> + <div class="content"> + <p>{{ $ts._mfm.searchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_search"/> + <MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.flip }}</div> + <div class="content"> + <p>{{ $ts._mfm.flipDescription }}</p> + <div class="preview"> + <Mfm :text="preview_flip"/> + <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.font }}</div> + <div class="content"> + <p>{{ $ts._mfm.fontDescription }}</p> + <div class="preview"> + <Mfm :text="preview_font"/> + <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x2 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x2Description }}</p> + <div class="preview"> + <Mfm :text="preview_x2"/> + <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x3 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x3Description }}</p> + <div class="preview"> + <Mfm :text="preview_x3"/> + <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x4 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x4Description }}</p> + <div class="preview"> + <Mfm :text="preview_x4"/> + <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.blur }}</div> + <div class="content"> + <p>{{ $ts._mfm.blurDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blur"/> + <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.jelly }}</div> + <div class="content"> + <p>{{ $ts._mfm.jellyDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jelly"/> + <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.tada }}</div> + <div class="content"> + <p>{{ $ts._mfm.tadaDescription }}</p> + <div class="preview"> + <Mfm :text="preview_tada"/> + <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.jump }}</div> + <div class="content"> + <p>{{ $ts._mfm.jumpDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jump"/> + <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.bounce }}</div> + <div class="content"> + <p>{{ $ts._mfm.bounceDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bounce"/> + <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.spin }}</div> + <div class="content"> + <p>{{ $ts._mfm.spinDescription }}</p> + <div class="preview"> + <Mfm :text="preview_spin"/> + <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.shake }}</div> + <div class="content"> + <p>{{ $ts._mfm.shakeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_shake"/> + <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.twitch }}</div> + <div class="content"> + <p>{{ $ts._mfm.twitchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_twitch"/> + <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.rainbow }}</div> + <div class="content"> + <p>{{ $ts._mfm.rainbowDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rainbow"/> + <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> + </div> + </div> + </div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.sparkle }}</div> + <div class="content"> + <p>{{ $ts._mfm.sparkleDescription }}</p> + <div class="preview"> + <Mfm :text="preview_sparkle"/> + <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkTextarea + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._mfm.cheatSheet, + icon: 'fas fa-question-circle', + }, + preview_mention: '@example', + preview_hashtag: '#test', + preview_url: `https://example.com`, + preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`, + preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`, + preview_bold: `**${this.$ts._mfm.dummy}**`, + preview_small: `<small>${this.$ts._mfm.dummy}</small>`, + preview_center: `<center>${this.$ts._mfm.dummy}</center>`, + preview_inlineCode: '`<: "Hello, world!"`', + preview_blockCode: '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```', + preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)', + preview_quote: `> ${this.$ts._mfm.dummy}`, + preview_search: `${this.$ts._mfm.dummy} 検索`, + preview_jelly: `$[jelly 🍮]`, + preview_tada: `$[tada 🍮]`, + preview_jump: `$[jump 🍮]`, + preview_bounce: `$[bounce 🍮]`, + preview_shake: `$[shake 🍮]`, + preview_twitch: `$[twitch 🍮]`, + preview_spin: `$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]`, + preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`, + preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`, + preview_x2: `$[x2 🍮]`, + preview_x3: `$[x3 🍮]`, + preview_x4: `$[x4 🍮]`, + preview_blur: `$[blur ${this.$ts._mfm.dummy}]`, + preview_rainbow: `$[rainbow 🍮]`, + preview_sparkle: `$[sparkle 🍮]`, + } + }, +}); +</script> + +<style lang="scss" scoped> +.mwysmxbg { + background: var(--bg); + + > .section { + > .title { + position: sticky; + z-index: 1; + top: var(--stickyTop, 0px); + padding: 16px; + font-weight: bold; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + background-color: var(--X16); + } + + > .content { + > p { + margin: 0; + padding: 16px; + } + + > .preview { + border-top: solid 0.5px var(--divider); + padding: 16px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue new file mode 100644 index 0000000000..6430588c46 --- /dev/null +++ b/packages/client/src/pages/miauth.vue @@ -0,0 +1,100 @@ +<template> +<div v-if="$i"> + <div class="waiting _section" v-if="state == 'waiting'"> + <div class="_content"> + <MkLoading/> + </div> + </div> + <div class="denied _section" v-if="state == 'denied'"> + <div class="_content"> + <p>{{ $ts._auth.denied }}</p> + </div> + </div> + <div class="accepted _section" v-else-if="state == 'accepted'"> + <div class="_content"> + <p v-if="callback">{{ $ts._auth.callback }}<MkEllipsis/></p> + <p v-else>{{ $ts._auth.pleaseGoBack }}</p> + </div> + </div> + <div class="_section" v-else> + <div class="_title" v-if="name">{{ $t('_auth.shareAccess', { name: name }) }}</div> + <div class="_title" v-else>{{ $ts._auth.shareAccessAsk }}</div> + <div class="_content"> + <p>{{ $ts._auth.permissionAsk }}</p> + <ul> + <li v-for="p in permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </div> + <div class="_footer"> + <MkButton @click="deny" inline>{{ $ts.cancel }}</MkButton> + <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton> + </div> + </div> +</div> +<div class="signin" v-else> + <MkSignin @login="onLogin"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkSignin from '@/components/signin.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + MkSignin, + MkButton, + }, + data() { + return { + state: null + }; + }, + computed: { + session(): string { + return this.$route.params.session; + }, + callback(): string { + return this.$route.query.callback; + }, + name(): string { + return this.$route.query.name; + }, + icon(): string { + return this.$route.query.icon; + }, + permission(): string[] { + return this.$route.query.permission ? this.$route.query.permission.split(',') : []; + }, + }, + methods: { + async accept() { + this.state = 'waiting'; + await os.api('miauth/gen-token', { + session: this.session, + name: this.name, + iconUrl: this.icon, + permission: this.permission, + }); + + this.state = 'accepted'; + if (this.callback) { + location.href = `${this.callback}?session=${this.session}`; + } + }, + deny() { + this.state = 'denied'; + }, + onLogin(res) { + login(res.i); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue new file mode 100644 index 0000000000..173807475a --- /dev/null +++ b/packages/client/src/pages/my-antennas/create.vue @@ -0,0 +1,51 @@ +<template> +<div class="geegznzt"> + <XAntenna :antenna="draft" @created="onAntennaCreated"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XAntenna from './editor.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + XAntenna, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageAntennas, + icon: 'fas fa-satellite', + }, + draft: { + name: '', + src: 'all', + userListId: null, + userGroupId: null, + users: [], + keywords: [], + excludeKeywords: [], + withReplies: false, + caseSensitive: false, + withFile: false, + notify: false + }, + }; + }, + + methods: { + onAntennaCreated() { + this.$router.push('/my/antennas'); + }, + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue new file mode 100644 index 0000000000..04928c81a3 --- /dev/null +++ b/packages/client/src/pages/my-antennas/edit.vue @@ -0,0 +1,56 @@ +<template> +<div class=""> + <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XAntenna from './editor.vue'; +import * as symbols from '@/symbols'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + XAntenna, + }, + + props: { + antennaId: { + type: String, + required: true, + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageAntennas, + icon: 'fas fa-satellite', + }, + antenna: null, + }; + }, + + watch: { + antennaId: { + async handler() { + this.antenna = await os.api('antennas/show', { antennaId: this.antennaId }); + }, + immediate: true, + } + }, + + methods: { + onAntennaUpdated() { + this.$router.push('/my/antennas'); + }, + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue new file mode 100644 index 0000000000..5ad3d50486 --- /dev/null +++ b/packages/client/src/pages/my-antennas/editor.vue @@ -0,0 +1,190 @@ +<template> +<div class="shaynizk"> + <div class="form"> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ $ts.name }}</template> + </MkInput> + <MkSelect v-model="src" class="_formBlock"> + <template #label>{{ $ts.antennaSource }}</template> + <option value="all">{{ $ts._antennaSources.all }}</option> + <option value="home">{{ $ts._antennaSources.homeTimeline }}</option> + <option value="users">{{ $ts._antennaSources.users }}</option> + <option value="list">{{ $ts._antennaSources.userList }}</option> + <option value="group">{{ $ts._antennaSources.userGroup }}</option> + </MkSelect> + <MkSelect v-model="userListId" v-if="src === 'list'" class="_formBlock"> + <template #label>{{ $ts.userList }}</template> + <option v-for="list in userLists" :value="list.id" :key="list.id">{{ list.name }}</option> + </MkSelect> + <MkSelect v-model="userGroupId" v-else-if="src === 'group'" class="_formBlock"> + <template #label>{{ $ts.userGroup }}</template> + <option v-for="group in userGroups" :value="group.id" :key="group.id">{{ group.name }}</option> + </MkSelect> + <MkTextarea v-model="users" v-else-if="src === 'users'" class="_formBlock"> + <template #label>{{ $ts.users }}</template> + <template #caption>{{ $ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ $ts.addUser }}</button></template> + </MkTextarea> + <MkSwitch v-model="withReplies" class="_formBlock">{{ $ts.withReplies }}</MkSwitch> + <MkTextarea v-model="keywords" class="_formBlock"> + <template #label>{{ $ts.antennaKeywords }}</template> + <template #caption>{{ $ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkTextarea v-model="excludeKeywords" class="_formBlock"> + <template #label>{{ $ts.antennaExcludeKeywords }}</template> + <template #caption>{{ $ts.antennaKeywordsDescription }}</template> + </MkTextarea> + <MkSwitch v-model="caseSensitive" class="_formBlock">{{ $ts.caseSensitive }}</MkSwitch> + <MkSwitch v-model="withFile" class="_formBlock">{{ $ts.withFileAntenna }}</MkSwitch> + <MkSwitch v-model="notify" class="_formBlock">{{ $ts.notifyAntenna }}</MkSwitch> + </div> + <div class="actions"> + <MkButton inline @click="saveAntenna()" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton inline @click="deleteAntenna()" v-if="antenna.id != null" danger><i class="fas fa-trash"></i> {{ $ts.delete }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, MkInput, MkTextarea, MkSelect, MkSwitch + }, + + props: { + antenna: { + type: Object, + required: true + } + }, + + data() { + return { + name: '', + src: '', + userListId: null, + userGroupId: null, + users: '', + keywords: '', + excludeKeywords: '', + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + userLists: null, + userGroups: null, + }; + }, + + watch: { + async src() { + if (this.src === 'list' && this.userLists === null) { + this.userLists = await os.api('users/lists/list'); + } + + if (this.src === 'group' && this.userGroups === null) { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + + this.userGroups = [...groups1, ...groups2]; + } + } + }, + + created() { + this.name = this.antenna.name; + this.src = this.antenna.src; + this.userListId = this.antenna.userListId; + this.userGroupId = this.antenna.userGroupId; + this.users = this.antenna.users.join('\n'); + this.keywords = this.antenna.keywords.map(x => x.join(' ')).join('\n'); + this.excludeKeywords = this.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'); + this.caseSensitive = this.antenna.caseSensitive; + this.withReplies = this.antenna.withReplies; + this.withFile = this.antenna.withFile; + this.notify = this.antenna.notify; + }, + + methods: { + async saveAntenna() { + if (this.antenna.id == null) { + await os.apiWithDialog('antennas/create', { + name: this.name, + src: this.src, + userListId: this.userListId, + userGroupId: this.userGroupId, + withReplies: this.withReplies, + withFile: this.withFile, + notify: this.notify, + caseSensitive: this.caseSensitive, + users: this.users.trim().split('\n').map(x => x.trim()), + keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')), + excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.$emit('created'); + } else { + await os.apiWithDialog('antennas/update', { + antennaId: this.antenna.id, + name: this.name, + src: this.src, + userListId: this.userListId, + userGroupId: this.userGroupId, + withReplies: this.withReplies, + withFile: this.withFile, + notify: this.notify, + caseSensitive: this.caseSensitive, + users: this.users.trim().split('\n').map(x => x.trim()), + keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')), + excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.$emit('updated'); + } + }, + + async deleteAntenna() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.antenna.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.api('antennas/delete', { + antennaId: this.antenna.id, + }); + + os.success(); + this.$emit('deleted'); + }, + + addUser() { + os.selectUser().then(user => { + this.users = this.users.trim(); + this.users += '\n@' + Acct.toString(user); + this.users = this.users.trim(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.shaynizk { + > .form { + padding: 32px; + } + + > .actions { + padding: 24px 32px; + border-top: solid 0.5px var(--divider); + } +} +</style> diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue new file mode 100644 index 0000000000..029f1949d7 --- /dev/null +++ b/packages/client/src/pages/my-antennas/index.vue @@ -0,0 +1,71 @@ +<template> +<div class="ieepwinx _section"> + <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + + <div class="_content"> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <MkA class="ljoevbzj" v-for="antenna in items" :key="antenna.id" :to="`/my/antennas/${antenna.id}`"> + <div class="name">{{ antenna.name }}</div> + </MkA> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageAntennas, + icon: 'fas fa-satellite', + action: { + icon: 'fas fa-plus', + handler: this.create + } + }, + pagination: { + endpoint: 'antennas/list', + limit: 10, + }, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ieepwinx { + padding: 16px; + + > .add { + margin: 0 auto 16px auto; + } + + .ljoevbzj { + display: block; + padding: 16px; + margin-bottom: 8px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue new file mode 100644 index 0000000000..cbcdb85fa5 --- /dev/null +++ b/packages/client/src/pages/my-clips/index.vue @@ -0,0 +1,104 @@ +<template> +<div class="_section qtcaoidl"> + <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> + + <div class="_content"> + <MkPagination :pagination="pagination" #default="{items}" ref="list" class="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.clip, + icon: 'fas fa-paperclip', + action: { + icon: 'fas fa-plus', + handler: this.create + } + }, + pagination: { + endpoint: 'clips/list', + limit: 10, + }, + draft: null, + }; + }, + + methods: { + async create() { + 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; + + os.apiWithDialog('clips/create', result); + }, + + onClipCreated() { + this.$refs.list.reload(); + this.draft = null; + }, + + onClipDeleted() { + this.$refs.list.reload(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.qtcaoidl { + > .add { + margin: 0 auto 16px auto; + } + + > ._content { + > .list { + > .item { + display: block; + padding: 16px; + + > .description { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue new file mode 100644 index 0000000000..9548c374d2 --- /dev/null +++ b/packages/client/src/pages/my-groups/group.vue @@ -0,0 +1,184 @@ +<template> +<div class="mk-group-page"> + <transition name="zoom" mode="out-in"> + <div v-if="group" class="_section"> + <div class="_content"> + <MkButton inline @click="invite()">{{ $ts.invite }}</MkButton> + <MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton> + <MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton> + <MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton> + </div> + </div> + </transition> + + <transition name="zoom" mode="out-in"> + <div v-if="group" class="_section members _gap"> + <div class="_title">{{ $ts.members }}</div> + <div class="_content"> + <div class="users"> + <div class="user _panel" v-for="user in users" :key="user.id"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + <div class="action"> + <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button> + </div> + </div> + </div> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + groupId: { + type: String, + required: true, + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.group ? { + title: this.group.name, + icon: 'fas fa-users', + } : null), + group: null, + users: [], + }; + }, + + watch: { + groupId: 'fetch', + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + Progress.start(); + os.api('users/groups/show', { + groupId: this.groupId + }).then(group => { + this.group = group; + os.api('users/show', { + userIds: this.group.userIds + }).then(users => { + this.users = users; + Progress.done(); + }); + }); + }, + + invite() { + os.selectUser().then(user => { + os.apiWithDialog('users/groups/invite', { + groupId: this.group.id, + userId: user.id + }); + }); + }, + + removeUser(user) { + os.api('users/groups/pull', { + groupId: this.group.id, + userId: user.id + }).then(() => { + this.users = this.users.filter(x => x.id !== user.id); + }); + }, + + async renameGroup() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.groupName, + input: { + default: this.group.name + } + }); + if (canceled) return; + + await os.api('users/groups/update', { + groupId: this.group.id, + name: name + }); + + this.group.name = name; + }, + + transfer() { + os.selectUser().then(user => { + os.apiWithDialog('users/groups/transfer', { + groupId: this.group.id, + userId: user.id + }); + }); + }, + + async deleteGroup() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.group.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.apiWithDialog('users/groups/delete', { + groupId: this.group.id + }); + this.$router.push('/my/groups'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-group-page { + > .members { + > ._content { + > .users { + > .user { + display: flex; + align-items: center; + padding: 16px; + + > .avatar { + width: 50px; + height: 50px; + } + + > .body { + flex: 1; + padding: 8px; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue new file mode 100644 index 0000000000..77e7d6088e --- /dev/null +++ b/packages/client/src/pages/my-groups/index.vue @@ -0,0 +1,121 @@ +<template> +<div class=""> + <div class="_section" style="padding: 0;"> + <MkTab v-model="tab"> + <option value="owned">{{ $ts.ownedGroups }}</option> + <option value="joined">{{ $ts.joinedGroups }}</option> + <option value="invites"><i class="fas fa-envelope-open-text"></i> {{ $ts.invites }}</option> + </MkTab> + </div> + + <div class="_section"> + <div class="_content" v-if="tab === 'owned'"> + <MkButton @click="create" primary style="margin: 0 auto var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton> + + <MkPagination :pagination="ownedPagination" #default="{items}" ref="owned"> + <div class="_card" v-for="group in items" :key="group.id"> + <div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div> + <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> + </div> + </MkPagination> + </div> + + <div class="_content" v-else-if="tab === 'joined'"> + <MkPagination :pagination="joinedPagination" #default="{items}" ref="joined"> + <div class="_card" v-for="group in items" :key="group.id"> + <div class="_title">{{ group.name }}</div> + <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> + </div> + </MkPagination> + </div> + + <div class="_content" v-else-if="tab === 'invites'"> + <MkPagination :pagination="invitationPagination" #default="{items}" ref="invitations"> + <div class="_card" v-for="invitation in items" :key="invitation.id"> + <div class="_title">{{ invitation.group.name }}</div> + <div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div> + <div class="_footer"> + <MkButton @click="acceptInvite(invitation)" primary inline><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton> + <MkButton @click="rejectInvite(invitation)" primary inline><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton> + </div> + </div> + </MkPagination> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkAvatars from '@/components/avatars.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + MkContainer, + MkTab, + MkAvatars, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.groups, + icon: 'fas fa-users' + }, + tab: 'owned', + ownedPagination: { + endpoint: 'users/groups/owned', + limit: 10, + }, + joinedPagination: { + endpoint: 'users/groups/joined', + limit: 10, + }, + invitationPagination: { + endpoint: 'i/user-group-invites', + limit: 10, + }, + }; + }, + + methods: { + async create() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.groupName, + input: true + }); + if (canceled) return; + await os.api('users/groups/create', { name: name }); + this.$refs.owned.reload(); + os.success(); + }, + acceptInvite(invitation) { + os.api('users/groups/invitations/accept', { + invitationId: invitation.id + }).then(() => { + os.success(); + this.$refs.invitations.reload(); + this.$refs.joined.reload(); + }); + }, + rejectInvite(invitation) { + os.api('users/groups/invitations/reject', { + invitationId: invitation.id + }).then(() => { + this.$refs.invitations.reload(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue new file mode 100644 index 0000000000..adb59db665 --- /dev/null +++ b/packages/client/src/pages/my-lists/index.vue @@ -0,0 +1,88 @@ +<template> +<div class="qkcjvfiv"> + <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton> + + <MkPagination :pagination="pagination" #default="{items}" class="lists _content" ref="list"> + <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`"> + <div class="name">{{ list.name }}</div> + <MkAvatars :user-ids="list.userIds"/> + </MkA> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkAvatars from '@/components/avatars.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + MkAvatars, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.manageLists, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', + action: { + icon: 'fas fa-plus', + handler: this.create + }, + }, + pagination: { + endpoint: 'users/lists/list', + limit: 10, + }, + }; + }, + + methods: { + async create() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.enterListName, + input: true + }); + if (canceled) return; + await os.api('users/lists/create', { name: name }); + this.$refs.list.reload(); + os.success(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.qkcjvfiv { + padding: 16px; + + > .add { + margin: 0 auto var(--margin) auto; + } + + > .lists { + > .list { + display: block; + padding: 16px; + border: solid 1px var(--divider); + border-radius: 6px; + + &:hover { + border: solid 1px var(--accent); + text-decoration: none; + } + + > .name { + margin-bottom: 4px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue new file mode 100644 index 0000000000..f2a02cadc9 --- /dev/null +++ b/packages/client/src/pages/my-lists/list.vue @@ -0,0 +1,170 @@ +<template> +<div class="mk-list-page"> + <transition name="zoom" mode="out-in"> + <div v-if="list" class="_section"> + <div class="_content"> + <MkButton inline @click="addUser()">{{ $ts.addUser }}</MkButton> + <MkButton inline @click="renameList()">{{ $ts.rename }}</MkButton> + <MkButton inline @click="deleteList()">{{ $ts.delete }}</MkButton> + </div> + </div> + </transition> + + <transition name="zoom" mode="out-in"> + <div v-if="list" class="_section members _gap"> + <div class="_title">{{ $ts.members }}</div> + <div class="_content"> + <div class="users"> + <div class="user _panel" v-for="user in users" :key="user.id"> + <MkAvatar :user="user" class="avatar" :show-indicator="true"/> + <div class="body"> + <MkUserName :user="user" class="name"/> + <MkAcct :user="user" class="acct"/> + </div> + <div class="action"> + <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button> + </div> + </div> + </div> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.list ? { + title: this.list.name, + icon: 'fas fa-list-ul', + } : null), + list: null, + users: [], + }; + }, + + watch: { + $route: 'fetch' + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + Progress.start(); + os.api('users/lists/show', { + listId: this.$route.params.list + }).then(list => { + this.list = list; + os.api('users/show', { + userIds: this.list.userIds + }).then(users => { + this.users = users; + Progress.done(); + }); + }); + }, + + addUser() { + os.selectUser().then(user => { + os.apiWithDialog('users/lists/push', { + listId: this.list.id, + userId: user.id + }).then(() => { + this.users.push(user); + }); + }); + }, + + removeUser(user) { + os.api('users/lists/pull', { + listId: this.list.id, + userId: user.id + }).then(() => { + this.users = this.users.filter(x => x.id !== user.id); + }); + }, + + async renameList() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.enterListName, + input: { + default: this.list.name + } + }); + if (canceled) return; + + await os.api('users/lists/update', { + listId: this.list.id, + name: name + }); + + this.list.name = name; + }, + + async deleteList() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.list.name }), + showCancelButton: true + }); + if (canceled) return; + + await os.api('users/lists/delete', { + listId: this.list.id + }); + os.success(); + this.$router.push('/my/lists'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-list-page { + > .members { + > ._content { + > .users { + > .user { + display: flex; + align-items: center; + padding: 16px; + + > .avatar { + width: 50px; + height: 50px; + } + + > .body { + flex: 1; + padding: 8px; + + > .name { + display: block; + font-weight: bold; + } + + > .acct { + opacity: 0.5; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue new file mode 100644 index 0000000000..92d3f399f7 --- /dev/null +++ b/packages/client/src/pages/not-found.vue @@ -0,0 +1,25 @@ +<template> +<div class="ipledcug"> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/> + <div>{{ $ts.notFoundDescription }}</div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.notFound, + icon: 'fas fa-exclamation-triangle' + }, + } + }, +}); +</script> diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue new file mode 100644 index 0000000000..ecd391dfbf --- /dev/null +++ b/packages/client/src/pages/note.vue @@ -0,0 +1,209 @@ +<template> +<MkSpacer :content-max="800"> + <div class="fcuexfpr"> + <transition name="fade" mode="out-in"> + <div v-if="note" class="note"> + <div class="_gap" v-if="showNext"> + <XNotes class="_content" :pagination="next" :no-gap="true"/> + </div> + + <div class="main _gap"> + <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton> + <div class="note _gap"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_isolated"/> + <XNoteDetailed v-model:note="note" :key="note.id" class="_isolated note"/> + </div> + <div class="_content clips _gap" v-if="clips && clips.length > 0"> + <div class="title">{{ $ts.clip }}</div> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + <div class="user"> + <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> + </div> + </MkA> + </div> + <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton> + </div> + + <div class="_gap" v-if="showPrev"> + <XNotes class="_content" :pagination="prev" :no-gap="true"/> + </div> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XNote from '@/components/note.vue'; +import XNoteDetailed from '@/components/note-detailed.vue'; +import XNotes from '@/components/notes.vue'; +import MkRemoteCaution from '@/components/remote-caution.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNote, + XNoteDetailed, + XNotes, + MkRemoteCaution, + MkButton, + }, + props: { + noteId: { + type: String, + required: true + } + }, + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.note ? { + title: this.$ts.note, + subtitle: new Date(this.note.createdAt).toLocaleString(), + avatar: this.note.user, + path: `/notes/${this.note.id}`, + share: { + title: this.$t('noteOf', { user: this.note.user.name }), + text: this.note.text, + }, + bg: 'var(--bg)', + } : null), + note: null, + clips: null, + hasPrev: false, + hasNext: false, + showPrev: false, + showNext: false, + error: null, + prev: { + endpoint: 'users/notes', + limit: 10, + params: init => ({ + userId: this.note.userId, + untilId: this.note.id, + }) + }, + next: { + reversed: true, + endpoint: 'users/notes', + limit: 10, + params: init => ({ + userId: this.note.userId, + sinceId: this.note.id, + }) + }, + }; + }, + watch: { + noteId: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + this.note = null; + os.api('notes/show', { + noteId: this.noteId + }).then(note => { + this.note = note; + Promise.all([ + os.api('notes/clips', { + noteId: note.id, + }), + os.api('users/notes', { + userId: note.userId, + untilId: note.id, + limit: 1, + }), + os.api('users/notes', { + userId: note.userId, + sinceId: note.id, + limit: 1, + }), + ]).then(([clips, prev, next]) => { + this.clips = clips; + this.hasPrev = prev.length !== 0; + this.hasNext = next.length !== 0; + }); + }).catch(e => { + this.error = e; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.fcuexfpr { + background: var(--bg); + + > .note { + > .main { + > .load { + min-width: 0; + margin: 0 auto; + border-radius: 999px; + + &.next { + margin-bottom: var(--margin); + } + + &.prev { + margin-top: var(--margin); + } + } + + > .note { + > .note { + border-radius: var(--radius); + background: var(--panel); + } + } + + > .clips { + > .title { + font-weight: bold; + padding: 12px; + } + + > .item { + display: block; + padding: 16px; + + > .description { + padding: 8px 0; + } + + > .user { + $height: 32px; + padding-top: 16px; + border-top: solid 0.5px var(--divider); + line-height: $height; + + > .avatar { + width: $height; + height: $height; + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue new file mode 100644 index 0000000000..f8e610a719 --- /dev/null +++ b/packages/client/src/pages/notifications.vue @@ -0,0 +1,88 @@ +<template> +<MkSpacer :content-max="800"> + <div class="clupoqwt"> + <XNotifications class="notifications" @before="before" @after="after" :include-types="includeTypes" :unread-only="tab === 'unread'"/> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotifications from '@/components/notifications.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { notificationTypes } from 'misskey-js'; + +export default defineComponent({ + components: { + XNotifications + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.notifications, + icon: 'fas fa-bell', + bg: 'var(--bg)', + actions: [{ + text: this.$ts.filter, + icon: 'fas fa-filter', + highlighted: this.includeTypes != null, + handler: this.setFilter, + }, { + text: this.$ts.markAllAsRead, + icon: 'fas fa-check', + handler: () => { + os.apiWithDialog('notifications/mark-all-as-read'); + }, + }], + tabs: [{ + active: this.tab === 'all', + title: this.$ts.all, + onClick: () => { this.tab = 'all'; }, + }, { + active: this.tab === 'unread', + title: this.$ts.unread, + onClick: () => { this.tab = 'unread'; }, + },] + })), + tab: 'all', + includeTypes: null, + }; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + setFilter(ev) { + const typeItems = notificationTypes.map(t => ({ + text: this.$t(`_notification._types.${t}`), + active: this.includeTypes && this.includeTypes.includes(t), + action: () => { + this.includeTypes = [t]; + } + })); + const items = this.includeTypes != null ? [{ + icon: 'fas fa-times', + text: this.$ts.clear, + action: () => { + this.includeTypes = null; + } + }, null, ...typeItems] : typeItems; + os.popupMenu(items, ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.clupoqwt { +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue new file mode 100644 index 0000000000..a25a892eaa --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue @@ -0,0 +1,84 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.button }}</template> + + <section class="xfhsjczc"> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._button.text }}</template></MkInput> + <MkSwitch v-model="value.primary"><span>{{ $ts._pages.blocks._button.colored }}</span></MkSwitch> + <MkSelect v-model="value.action"> + <template #label>{{ $ts._pages.blocks._button.action }}</template> + <option value="dialog">{{ $ts._pages.blocks._button._action.dialog }}</option> + <option value="resetRandom">{{ $ts._pages.blocks._button._action.resetRandom }}</option> + <option value="pushEvent">{{ $ts._pages.blocks._button._action.pushEvent }}</option> + <option value="callAiScript">{{ $ts._pages.blocks._button._action.callAiScript }}</option> + </MkSelect> + <template v-if="value.action === 'dialog'"> + <MkInput v-model="value.content"><template #label>{{ $ts._pages.blocks._button._action._dialog.content }}</template></MkInput> + </template> + <template v-else-if="value.action === 'pushEvent'"> + <MkInput v-model="value.event"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.event }}</template></MkInput> + <MkInput v-model="value.message"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.message }}</template></MkInput> + <MkSelect v-model="value.var"> + <template #label>{{ $ts._pages.blocks._button._action._pushEvent.variable }}</template> + <option :value="null">{{ $t('_pages.blocks._button._action._pushEvent.no-variable') }}</option> + <option v-for="v in hpml.getVarsByType()" :value="v.name">{{ v.name }}</option> + <optgroup :label="$ts._pages.script.pageVariables"> + <option v-for="v in hpml.getPageVarsByType()" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.enviromentVariables"> + <option v-for="v in hpml.getEnvVarsByType()" :value="v">{{ v }}</option> + </optgroup> + </MkSelect> + </template> + <template v-else-if="value.action === 'callAiScript'"> + <MkInput v-model="value.fn"><template #label>{{ $ts._pages.blocks._button._action._callAiScript.functionName }}</template></MkInput> + </template> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkSelect, MkInput, MkSwitch + }, + + props: { + value: { + required: true + }, + hpml: { + required: true, + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + if (this.value.action == null) this.value.action = 'dialog'; + if (this.value.content == null) this.value.content = null; + if (this.value.event == null) this.value.event = null; + if (this.value.message == null) this.value.message = null; + if (this.value.primary == null) this.value.primary = false; + if (this.value.var == null) this.value.var = null; + if (this.value.fn == null) this.value.fn = null; + }, +}); +</script> + +<style lang="scss" scoped> +.xfhsjczc { + padding: 0 16px 0 16px; +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue new file mode 100644 index 0000000000..5d009561e2 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue @@ -0,0 +1,50 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-paint-brush"></i> {{ $ts._pages.blocks.canvas }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"> + <template #prefix><i class="fas fa-magic"></i></template> + <template #label>{{ $ts._pages.blocks._canvas.id }}</template> + </MkInput> + <MkInput v-model="value.width" type="number"> + <template #label>{{ $ts._pages.blocks._canvas.width }}</template> + <template #suffix>px</template> + </MkInput> + <MkInput v-model="value.height" type="number"> + <template #label>{{ $ts._pages.blocks._canvas.height }}</template> + <template #suffix>px</template> + </MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + if (this.value.width == null) this.value.width = 300; + if (this.value.height == null) this.value.height = 200; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue new file mode 100644 index 0000000000..3704c64250 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue @@ -0,0 +1,46 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.counter }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"> + <template #prefix><i class="fas fa-magic"></i></template> + <template #label>{{ $ts._pages.blocks._counter.name }}</template> + </MkInput> + <MkInput v-model="value.text"> + <template #label>{{ $ts._pages.blocks._counter.text }}</template> + </MkInput> + <MkInput v-model="value.inc" type="number"> + <template #label>{{ $ts._pages.blocks._counter.inc }}</template> + </MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue new file mode 100644 index 0000000000..f76d59abe3 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue @@ -0,0 +1,84 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-question"></i> {{ $ts._pages.blocks.if }}</template> + <template #func> + <button @click="add()" class="_button"> + <i class="fas fa-plus"></i> + </button> + </template> + + <section class="romcojzs"> + <MkSelect v-model="value.var"> + <template #label>{{ $ts._pages.blocks._if.variable }}</template> + <option v-for="v in hpml.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option> + <optgroup :label="$ts._pages.script.pageVariables"> + <option v-for="v in hpml.getPageVarsByType('boolean')" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.enviromentVariables"> + <option v-for="v in hpml.getEnvVarsByType('boolean')" :value="v">{{ v }}</option> + </optgroup> + </MkSelect> + + <XBlocks class="children" v-model="value.children" :hpml="hpml"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from '../page-editor.container.vue'; +import MkSelect from '@/components/form/select.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkSelect, + XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')), + }, + + inject: ['getPageBlockList'], + + props: { + value: { + required: true + }, + hpml: { + required: true, + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.children == null) this.value.children = []; + if (this.value.var === undefined) this.value.var = null; + }, + + methods: { + async add() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.chooseBlock, + select: { + groupedItems: this.getPageBlockList() + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid(); + this.value.children.push({ id, type }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.romcojzs { + padding: 0 16px 16px 16px; +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue new file mode 100644 index 0000000000..396c83f512 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue @@ -0,0 +1,72 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-image"></i> {{ $ts._pages.blocks.image }}</template> + <template #func> + <button @click="choose()"> + <i class="fas fa-folder-open"></i> + </button> + </template> + + <section class="oyyftmcf"> + <MkDriveFileThumbnail class="preview" v-if="file" :file="file" fit="contain" @click="choose()"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkDriveFileThumbnail + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + file: null, + }; + }, + + created() { + if (this.value.fileId === undefined) this.value.fileId = null; + }, + + mounted() { + if (this.value.fileId == null) { + this.choose(); + } else { + os.api('drive/files/show', { + fileId: this.value.fileId + }).then(file => { + this.file = file; + }); + } + }, + + methods: { + async choose() { + os.selectDriveFile(false).then(file => { + this.file = file; + this.value.fileId = file.id; + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.oyyftmcf { + > .preview { + height: 150px; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue new file mode 100644 index 0000000000..263b60d3e0 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue @@ -0,0 +1,65 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-sticky-note"></i> {{ $ts._pages.blocks.note }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="id"> + <template #label>{{ $ts._pages.blocks._note.id }}</template> + <template #caption>{{ $ts._pages.blocks._note.idDescription }}</template> + </MkInput> + <MkSwitch v-model="value.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch> + + <XNote v-if="note && !value.detailed" v-model:note="note" :key="note.id + ':normal'" style="margin-bottom: 16px;"/> + <XNoteDetailed v-if="note && value.detailed" v-model:note="note" :key="note.id + ':detail'" style="margin-bottom: 16px;"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import XNote from '@/components/note.vue'; +import XNoteDetailed from '@/components/note-detailed.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput, MkSwitch, XNote, XNoteDetailed, + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + id: this.value.note, + note: null, + }; + }, + + watch: { + id: { + async handler() { + if (this.id && (this.id.startsWith('http://') || this.id.startsWith('https://'))) { + this.value.note = this.id.endsWith('/') ? this.id.substr(0, this.id.length - 1).split('/').pop() : this.id.split('/').pop(); + } else { + this.value.note = this.id; + } + + this.note = await os.api('notes/show', { noteId: this.value.note }); + }, + immediate: true + }, + }, + + created() { + if (this.value.note == null) this.value.note = null; + if (this.value.detailed == null) this.value.detailed = false; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue new file mode 100644 index 0000000000..3a2f4a762b --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue @@ -0,0 +1,46 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.numberInput }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"> + <template #prefix><i class="fas fa-magic"></i></template> + <template #label>{{ $ts._pages.blocks._numberInput.name }}</template> + </MkInput> + <MkInput v-model="value.text"> + <template #label>{{ $ts._pages.blocks._numberInput.text }}</template> + </MkInput> + <MkInput v-model="value.default" type="number"> + <template #label>{{ $ts._pages.blocks._numberInput.default }}</template> + </MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue new file mode 100644 index 0000000000..780786144e --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue @@ -0,0 +1,43 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-paper-plane"></i> {{ $ts._pages.blocks.post }}</template> + + <section style="padding: 16px;"> + <MkTextarea v-model="value.text"><template #label>{{ $ts._pages.blocks._post.text }}</template></MkTextarea> + <MkSwitch v-model="value.attachCanvasImage"><span>{{ $ts._pages.blocks._post.attachCanvasImage }}</span></MkSwitch> + <MkInput v-if="value.attachCanvasImage" v-model="value.canvasId"><template #label>{{ $ts._pages.blocks._post.canvasId }}</template></MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, MkInput, MkSwitch + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + if (this.value.attachCanvasImage == null) this.value.attachCanvasImage = false; + if (this.value.canvasId == null) this.value.canvasId = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue new file mode 100644 index 0000000000..f01a47c54a --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue @@ -0,0 +1,50 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.radioButton }}</template> + + <section style="padding: 0 16px 16px 16px;"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._radioButton.name }}</template></MkInput> + <MkInput v-model="value.title"><template #label>{{ $ts._pages.blocks._radioButton.title }}</template></MkInput> + <MkTextarea v-model="values"><template #label>{{ $ts._pages.blocks._radioButton.values }}</template></MkTextarea> + <MkInput v-model="value.default"><template #label>{{ $ts._pages.blocks._radioButton.default }}</template></MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, MkInput + }, + props: { + value: { + required: true + }, + }, + data() { + return { + values: '', + }; + }, + watch: { + values: { + handler() { + this.value.values = this.values.split('\n'); + }, + deep: true + } + }, + created() { + if (this.value.name == null) this.value.name = ''; + if (this.value.title == null) this.value.title = ''; + if (this.value.values == null) this.value.values = []; + this.values = this.value.values.join('\n'); + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue new file mode 100644 index 0000000000..16e32d8400 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue @@ -0,0 +1,96 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-sticky-note"></i> {{ value.title }}</template> + <template #func> + <button @click="rename()" class="_button"> + <i class="fas fa-pencil-alt"></i> + </button> + <button @click="add()" class="_button"> + <i class="fas fa-plus"></i> + </button> + </template> + + <section class="ilrvjyvi"> + <XBlocks class="children" v-model="value.children" :hpml="hpml"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, + XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')), + }, + + inject: ['getPageBlockList'], + + props: { + value: { + required: true + }, + hpml: { + required: true, + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.title == null) this.value.title = null; + if (this.value.children == null) this.value.children = []; + }, + + mounted() { + if (this.value.title == null) { + this.rename(); + } + }, + + methods: { + async rename() { + const { canceled, result: title } = await os.dialog({ + title: 'Enter title', + input: { + type: 'text', + default: this.value.title + }, + showCancelButton: true + }); + if (canceled) return; + this.value.title = title; + }, + + async add() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.chooseBlock, + select: { + groupedItems: this.getPageBlockList() + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid(); + this.value.children.push({ id, type }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.ilrvjyvi { + > .children { + padding: 16px; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue new file mode 100644 index 0000000000..e72f7b44d0 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue @@ -0,0 +1,46 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.switch }}</template> + + <section class="kjuadyyj"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._switch.name }}</template></MkInput> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._switch.text }}</template></MkInput> + <MkSwitch v-model="value.default"><span>{{ $ts._pages.blocks._switch.default }}</span></MkSwitch> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkSwitch, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> + +<style lang="scss" scoped> +.kjuadyyj { + padding: 0 16px 16px 16px; +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue new file mode 100644 index 0000000000..908862cf07 --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue @@ -0,0 +1,39 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textInput }}</template> + + <section style="padding: 0 16px 0 16px;"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textInput.name }}</template></MkInput> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textInput.text }}</template></MkInput> + <MkInput v-model="value.default" type="text"><template #label>{{ $ts._pages.blocks._textInput.default }}</template></MkInput> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue new file mode 100644 index 0000000000..05b1a9c67d --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue @@ -0,0 +1,57 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template> + + <section class="vckmsadr"> + <textarea v-model="value.text"></textarea> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + }, +}); +</script> + +<style lang="scss" scoped> +.vckmsadr { + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + box-sizing: border-box; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue new file mode 100644 index 0000000000..bb37158ecb --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue @@ -0,0 +1,40 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textareaInput }}</template> + + <section style="padding: 0 16px 16px 16px;"> + <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textareaInput.name }}</template></MkInput> + <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textareaInput.text }}</template></MkInput> + <MkTextarea v-model="value.default"><template #label>{{ $ts._pages.blocks._textareaInput.default }}</template></MkTextarea> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkInput from '@/components/form/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, MkInput + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.name == null) this.value.name = ''; + }, +}); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue new file mode 100644 index 0000000000..4ca83da17c --- /dev/null +++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue @@ -0,0 +1,57 @@ +<template> +<XContainer @remove="() => $emit('remove')" :draggable="true"> + <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.textarea }}</template> + + <section class="ihymsbbe"> + <textarea v-model="value.text"></textarea> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XContainer from '../page-editor.container.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + }; + }, + + created() { + if (this.value.text == null) this.value.text = ''; + }, +}); +</script> + +<style lang="scss" scoped> +.ihymsbbe { + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + min-width: 100%; + min-height: 150px; + border: none; + box-shadow: none; + padding: 16px; + background: transparent; + color: var(--fg); + font-size: 14px; + box-sizing: border-box; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/page-editor.blocks.vue b/packages/client/src/pages/page-editor/page-editor.blocks.vue new file mode 100644 index 0000000000..b91d9abae8 --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.blocks.vue @@ -0,0 +1,78 @@ +<template> +<XDraggable tag="div" v-model="blocks" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5"> + <template #item="{element}"> + <component :is="'x-' + element.type" :value="element" @update:value="updateItem" @remove="() => removeItem(element)" :hpml="hpml"/> + </template> +</XDraggable> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XSection from './els/page-editor.el.section.vue'; +import XText from './els/page-editor.el.text.vue'; +import XTextarea from './els/page-editor.el.textarea.vue'; +import XImage from './els/page-editor.el.image.vue'; +import XButton from './els/page-editor.el.button.vue'; +import XTextInput from './els/page-editor.el.text-input.vue'; +import XTextareaInput from './els/page-editor.el.textarea-input.vue'; +import XNumberInput from './els/page-editor.el.number-input.vue'; +import XSwitch from './els/page-editor.el.switch.vue'; +import XIf from './els/page-editor.el.if.vue'; +import XPost from './els/page-editor.el.post.vue'; +import XCounter from './els/page-editor.el.counter.vue'; +import XRadioButton from './els/page-editor.el.radio-button.vue'; +import XCanvas from './els/page-editor.el.canvas.vue'; +import XNote from './els/page-editor.el.note.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton, XCanvas, XNote + }, + + props: { + modelValue: { + type: Array, + required: true + }, + hpml: { + required: true, + }, + }, + + emits: ['update:modelValue'], + + computed: { + blocks: { + get() { + return this.modelValue; + }, + set(value) { + this.$emit('update:modelValue', value); + } + } + }, + + methods: { + updateItem(v) { + const i = this.blocks.findIndex(x => x.id === v.id); + const newValue = [ + ...this.blocks.slice(0, i), + v, + ...this.blocks.slice(i + 1) + ]; + this.$emit('update:modelValue', newValue); + }, + + removeItem(el) { + const i = this.blocks.findIndex(x => x.id === el.id); + const newValue = [ + ...this.blocks.slice(0, i), + ...this.blocks.slice(i + 1) + ]; + this.$emit('update:modelValue', newValue); + }, + } +}); +</script> diff --git a/packages/client/src/pages/page-editor/page-editor.container.vue b/packages/client/src/pages/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..afd261fac7 --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.container.vue @@ -0,0 +1,159 @@ +<template> +<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }"> + <header> + <div class="title"><slot name="header"></slot></div> + <div class="buttons"> + <slot name="func"></slot> + <button v-if="removable" @click="remove()" class="_button"> + <i class="fas fa-trash-alt"></i> + </button> + <button v-if="draggable" class="drag-handle _button"> + <i class="fas fa-bars"></i> + </button> + <button @click="toggleContent(!showBody)" 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> + </div> + </header> + <p v-show="showBody" class="error" v-if="error != null">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p> + <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p> + <div v-show="showBody" class="body"> + <slot></slot> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + expanded: { + type: Boolean, + default: true + }, + removable: { + type: Boolean, + default: true + }, + draggable: { + type: Boolean, + default: false + }, + error: { + required: false, + default: null + }, + warn: { + required: false, + default: null + } + }, + emits: ['toggle', 'remove'], + data() { + return { + showBody: this.expanded, + }; + }, + methods: { + toggleContent(show: boolean) { + this.showBody = show; + this.$emit('toggle', show); + }, + remove() { + this.$emit('remove'); + } + } +}); +</script> + +<style lang="scss" scoped> +.cpjygsrt { + position: relative; + overflow: hidden; + background: var(--panel); + border: solid 2px var(--X12); + border-radius: 6px; + + &:hover { + border: solid 2px var(--X13); + } + + &.warn { + border: solid 2px #dec44c; + } + + &.error { + border: solid 2px #f00; + } + + & + .cpjygsrt { + margin-top: 16px; + } + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + box-shadow: 0 1px rgba(#000, 0.07); + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; + + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + + .drag-handle { + cursor: move; + } + } + } + + > .warn { + color: #b19e49; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } + + > .error { + color: #f00; + margin: 0; + padding: 16px 16px 0 16px; + font-size: 14px; + } + + > .body { + ::v-deep(.juejbjww), ::v-deep(.eiipwacr) { + &:not(.inline):first-child { + margin-top: 28px; + } + + &:not(.inline):last-child { + margin-bottom: 20px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/page-editor/page-editor.script-block.vue b/packages/client/src/pages/page-editor/page-editor.script-block.vue new file mode 100644 index 0000000000..07958c902b --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.script-block.vue @@ -0,0 +1,281 @@ +<template> +<XContainer :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable"> + <template #header><i v-if="icon" :class="icon"></i> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template> + <template #func> + <button @click="changeType()" class="_button"> + <i class="fas fa-pencil-alt"></i> + </button> + </template> + + <section v-if="modelValue.type === null" class="pbglfege" @click="changeType()"> + {{ $ts._pages.script.emptySlot }} + </section> + <section v-else-if="modelValue.type === 'text'" class="tbwccoaw"> + <input v-model="modelValue.value"/> + </section> + <section v-else-if="modelValue.type === 'multiLineText'" class="tbwccoaw"> + <textarea v-model="modelValue.value"></textarea> + </section> + <section v-else-if="modelValue.type === 'textList'" class="tbwccoaw"> + <textarea v-model="modelValue.value" :placeholder="$ts._pages.script.blocks._textList.info"></textarea> + </section> + <section v-else-if="modelValue.type === 'number'" class="tbwccoaw"> + <input v-model="modelValue.value" type="number"/> + </section> + <section v-else-if="modelValue.type === 'ref'" class="hpdwcrvs"> + <select v-model="modelValue.value"> + <option v-for="v in hpml.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option> + <optgroup :label="$ts._pages.script.argVariables"> + <option v-for="v in fnSlots" :value="v.name">{{ v.name }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.pageVariables"> + <option v-for="v in hpml.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + <optgroup :label="$ts._pages.script.enviromentVariables"> + <option v-for="v in hpml.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option> + </optgroup> + </select> + </section> + <section v-else-if="modelValue.type === 'aiScriptVar'" class="tbwccoaw"> + <input v-model="modelValue.value"/> + </section> + <section v-else-if="modelValue.type === 'fn'" class="" style="padding:0 16px 16px 16px;"> + <MkTextarea v-model="slots"> + <template #label>{{ $ts._pages.script.blocks._fn.slots }}</template> + <template #caption>{{ $t('_pages.script.blocks._fn.slots-info') }}</template> + </MkTextarea> + <XV v-if="modelValue.value.expression" v-model="modelValue.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :hpml="hpml" :fn-slots="value.value.slots" :name="name"/> + </section> + <section v-else-if="modelValue.type.startsWith('fn:')" class="" style="padding:16px;"> + <XV v-for="(x, i) in modelValue.args" v-model="value.args[i]" :title="hpml.getVarByName(modelValue.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :hpml="hpml" :name="name" :key="i"/> + </section> + <section v-else class="" style="padding:16px;"> + <XV v-for="(x, i) in modelValue.args" v-model="modelValue.args[i]" :title="$t(`_pages.script.blocks._${modelValue.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :hpml="hpml" :name="name" :fn-slots="fnSlots" :key="i"/> + </section> +</XContainer> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XContainer from './page-editor.container.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import { blockDefs } from '@/scripts/hpml/index'; +import * as os from '@/os'; +import { isLiteralValue } from '@/scripts/hpml/expr'; +import { funcDefs } from '@/scripts/hpml/lib'; + +export default defineComponent({ + components: { + XContainer, MkTextarea, + XV: defineAsyncComponent(() => import('./page-editor.script-block.vue')), + }, + + inject: ['getScriptBlockList'], + + props: { + getExpectedType: { + required: false, + default: null + }, + modelValue: { + required: true + }, + title: { + required: false + }, + removable: { + required: false, + default: false + }, + hpml: { + required: true, + }, + name: { + required: true, + }, + fnSlots: { + required: false, + }, + draggable: { + required: false, + default: false + } + }, + + data() { + return { + error: null, + warn: null, + slots: '', + }; + }, + + computed: { + icon(): any { + if (this.modelValue.type === null) return null; + if (this.modelValue.type.startsWith('fn:')) return 'fas fa-plug'; + return blockDefs.find(x => x.type === this.modelValue.type).icon; + }, + typeText(): any { + if (this.modelValue.type === null) return null; + if (this.modelValue.type.startsWith('fn:')) return this.modelValue.type.split(':')[1]; + return this.$t(`_pages.script.blocks.${this.modelValue.type}`); + }, + }, + + watch: { + slots: { + handler() { + this.modelValue.value.slots = this.slots.split('\n').map(x => ({ + name: x, + type: null + })); + }, + deep: true + } + }, + + created() { + if (this.modelValue.value == null) this.modelValue.value = null; + + if (this.modelValue.value && this.modelValue.value.slots) this.slots = this.modelValue.value.slots.map(x => x.name).join('\n'); + + this.$watch(() => this.modelValue.type, (t) => { + this.warn = null; + + if (this.modelValue.type === 'fn') { + const id = uuid(); + this.modelValue.value = { + slots: [], + expression: { id, type: null } + }; + return; + } + + if (this.modelValue.type && this.modelValue.type.startsWith('fn:')) { + const fnName = this.modelValue.type.split(':')[1]; + const fn = this.hpml.getVarByName(fnName); + + const empties = []; + for (let i = 0; i < fn.value.slots.length; i++) { + const id = uuid(); + empties.push({ id, type: null }); + } + this.modelValue.args = empties; + return; + } + + if (isLiteralValue(this.modelValue)) return; + + const empties = []; + for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) { + const id = uuid(); + empties.push({ id, type: null }); + } + this.modelValue.args = empties; + + for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) { + const inType = funcDefs[this.modelValue.type].in[i]; + if (typeof inType !== 'number') { + if (inType === 'number') this.modelValue.args[i].type = 'number'; + if (inType === 'string') this.modelValue.args[i].type = 'text'; + } + } + }); + + this.$watch(() => this.modelValue.args, (args) => { + if (args == null) { + this.warn = null; + return; + } + const emptySlotIndex = args.findIndex(x => x.type === null); + if (emptySlotIndex !== -1 && emptySlotIndex < args.length) { + this.warn = { + slot: emptySlotIndex + }; + } else { + this.warn = null; + } + }, { + deep: true + }); + + this.$watch(() => this.hpml.variables, () => { + if (this.type != null && this.modelValue) { + this.error = this.hpml.typeCheck(this.modelValue); + } + }, { + deep: true + }); + }, + + methods: { + async changeType() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.selectType, + select: { + groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null) + }, + showCancelButton: true + }); + if (canceled) return; + this.modelValue.type = type; + }, + + _getExpectedType(slot: number) { + return this.hpml.getExpectedType(this.modelValue, slot); + } + } +}); +</script> + +<style lang="scss" scoped> +.turmquns { + opacity: 0.7; +} + +.pbglfege { + opacity: 0.5; + padding: 16px; + text-align: center; + cursor: pointer; + color: var(--fg); +} + +.tbwccoaw { + > input, + > textarea { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + width: 100%; + max-width: 100%; + min-width: 100%; + border: none; + box-shadow: none; + padding: 16px; + font-size: 16px; + background: transparent; + color: var(--fg); + box-sizing: border-box; + } + + > textarea { + min-height: 100px; + } +} + +.hpdwcrvs { + padding: 16px; + + > select { + display: block; + padding: 4px; + font-size: 16px; + width: 100%; + } +} +</style> diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue new file mode 100644 index 0000000000..684b1f8c75 --- /dev/null +++ b/packages/client/src/pages/page-editor/page-editor.vue @@ -0,0 +1,561 @@ +<template> +<div> + <div class="jqqmcavi" style="margin: 16px;"> + <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton> + <MkButton inline @click="save" primary class="button" v-if="!readonly"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton inline @click="duplicate" class="button" v-if="pageId"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton> + <MkButton inline @click="del" class="button" v-if="pageId && !readonly" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + </div> + + <div v-if="tab === 'settings'"> + <div style="padding: 16px;" class="_formRoot"> + <MkInput v-model="title" class="_formBlock"> + <template #label>{{ $ts._pages.title }}</template> + </MkInput> + + <MkInput v-model="summary" class="_formBlock"> + <template #label>{{ $ts._pages.summary }}</template> + </MkInput> + + <MkInput v-model="name" class="_formBlock"> + <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> + <template #label>{{ $ts._pages.url }}</template> + </MkInput> + + <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch> + + <MkSelect v-model="font" class="_formBlock"> + <template #label>{{ $ts._pages.font }}</template> + <option value="serif">{{ $ts._pages.fontSerif }}</option> + <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option> + </MkSelect> + + <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch> + + <div class="eyeCatch"> + <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/> + <MkButton @click="removeEyeCatchingImage()" v-if="!readonly"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton> + </div> + </div> + </div> + </div> + + <div v-else-if="tab === 'contents'"> + <div style="padding: 16px;"> + <XBlocks class="content" v-model="content" :hpml="hpml"/> + + <MkButton @click="add()" v-if="!readonly"><i class="fas fa-plus"></i></MkButton> + </div> + </div> + + <div v-else-if="tab === 'variables'"> + <div class="qmuvgica"> + <XDraggable tag="div" class="variables" v-show="variables.length > 0" v-model="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5"> + <template #item="{element}"> + <XVariable + :modelValue="element" + :removable="true" + @remove="() => removeVariable(element)" + :hpml="hpml" + :name="element.name" + :title="element.name" + :draggable="true" + /> + </template> + </XDraggable> + + <MkButton @click="addVariable()" class="add" v-if="!readonly"><i class="fas fa-plus"></i></MkButton> + </div> + </div> + + <div v-else-if="tab === 'script'"> + <div> + <MkTextarea class="_code" v-model="script"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import 'prismjs'; +import { highlight, languages } from 'prismjs/components/prism-core'; +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/themes/prism-okaidia.css'; +import 'vue-prism-editor/dist/prismeditor.min.css'; +import { v4 as uuid } from 'uuid'; +import XVariable from './page-editor.script-block.vue'; +import XBlocks from './page-editor.blocks.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkInput from '@/components/form/input.vue'; +import { blockDefs } from '@/scripts/hpml/index'; +import { HpmlTypeChecker } from '@/scripts/hpml/type-checker'; +import { url } from '@/config'; +import { collectPageVars } from '@/scripts/collect-page-vars'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), + XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput, + }, + + props: { + initPageId: { + type: String, + required: false + }, + initPageName: { + type: String, + required: false + }, + initUser: { + type: String, + required: false + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => { + let title = this.$ts._pages.newPage; + if (this.initPageId) { + title = this.$ts._pages.editPage; + } + else if (this.initPageName && this.initUser) { + title = this.$ts._pages.readPage; + } + return { + title: title, + icon: 'fas fa-pencil-alt', + bg: 'var(--bg)', + tabs: [{ + active: this.tab === 'settings', + title: this.$ts._pages.pageSetting, + icon: 'fas fa-cog', + onClick: () => { this.tab = 'settings'; }, + }, { + active: this.tab === 'contents', + title: this.$ts._pages.contents, + icon: 'fas fa-sticky-note', + onClick: () => { this.tab = 'contents'; }, + }, { + active: this.tab === 'variables', + title: this.$ts._pages.variables, + icon: 'fas fa-magic', + onClick: () => { this.tab = 'variables'; }, + }, { + active: this.tab === 'script', + title: this.$ts.script, + icon: 'fas fa-code', + onClick: () => { this.tab = 'script'; }, + }], + }; + }), + tab: 'settings', + author: this.$i, + readonly: false, + page: null, + pageId: null, + currentName: null, + title: '', + summary: null, + name: Date.now().toString(), + eyeCatchingImage: null, + eyeCatchingImageId: null, + font: 'sans-serif', + content: [], + alignCenter: false, + hideTitleWhenPinned: false, + variables: [], + hpml: null, + script: '', + url, + }; + }, + + watch: { + async eyeCatchingImageId() { + if (this.eyeCatchingImageId == null) { + this.eyeCatchingImage = null; + } else { + this.eyeCatchingImage = await os.api('drive/files/show', { + fileId: this.eyeCatchingImageId, + }); + } + }, + }, + + async created() { + this.hpml = new HpmlTypeChecker(); + + this.$watch('variables', () => { + this.hpml.variables = this.variables; + }, { deep: true }); + + this.$watch('content', () => { + this.hpml.pageVars = collectPageVars(this.content); + }, { deep: true }); + + if (this.initPageId) { + this.page = await os.api('pages/show', { + pageId: this.initPageId, + }); + } else if (this.initPageName && this.initUser) { + this.page = await os.api('pages/show', { + name: this.initPageName, + username: this.initUser, + }); + this.readonly = true; + } + + if (this.page) { + this.author = this.page.user; + this.pageId = this.page.id; + this.title = this.page.title; + this.name = this.page.name; + this.currentName = this.page.name; + this.summary = this.page.summary; + this.font = this.page.font; + this.script = this.page.script; + this.hideTitleWhenPinned = this.page.hideTitleWhenPinned; + this.alignCenter = this.page.alignCenter; + this.content = this.page.content; + this.variables = this.page.variables; + this.eyeCatchingImageId = this.page.eyeCatchingImageId; + } else { + const id = uuid(); + this.content = [{ + id, + type: 'text', + text: 'Hello World!' + }]; + } + }, + + provide() { + return { + readonly: this.readonly, + getScriptBlockList: this.getScriptBlockList, + getPageBlockList: this.getPageBlockList + } + }, + + methods: { + getSaveOptions() { + return { + title: this.title.trim(), + name: this.name.trim(), + summary: this.summary, + font: this.font, + script: this.script, + hideTitleWhenPinned: this.hideTitleWhenPinned, + alignCenter: this.alignCenter, + content: this.content, + variables: this.variables, + eyeCatchingImageId: this.eyeCatchingImageId, + }; + }, + + save() { + const options = this.getSaveOptions(); + + const onError = err => { + if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') { + if (err.info.param == 'name') { + os.dialog({ + type: 'error', + title: this.$ts._pages.invalidNameTitle, + text: this.$ts._pages.invalidNameText + }); + } + } else if (err.code == 'NAME_ALREADY_EXISTS') { + os.dialog({ + type: 'error', + text: this.$ts._pages.nameAlreadyExists + }); + } + }; + + if (this.pageId) { + options.pageId = this.pageId; + os.api('pages/update', options) + .then(page => { + this.currentName = this.name.trim(); + os.dialog({ + type: 'success', + text: this.$ts._pages.updated + }); + }).catch(onError); + } else { + os.api('pages/create', options) + .then(page => { + this.pageId = page.id; + this.currentName = this.name.trim(); + os.dialog({ + type: 'success', + text: this.$ts._pages.created + }); + this.$router.push(`/pages/edit/${this.pageId}`); + }).catch(onError); + } + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$t('removeAreYouSure', { x: this.title.trim() }), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.api('pages/delete', { + pageId: this.pageId, + }).then(() => { + os.dialog({ + type: 'success', + text: this.$ts._pages.deleted + }); + this.$router.push(`/pages`); + }); + }); + }, + + duplicate() { + this.title = this.title + ' - copy'; + this.name = this.name + '-copy'; + os.api('pages/create', this.getSaveOptions()).then(page => { + this.pageId = page.id; + this.currentName = this.name.trim(); + os.dialog({ + type: 'success', + text: this.$ts._pages.created + }); + this.$router.push(`/pages/edit/${this.pageId}`); + }); + }, + + async add() { + const { canceled, result: type } = await os.dialog({ + type: null, + title: this.$ts._pages.chooseBlock, + select: { + groupedItems: this.getPageBlockList() + }, + showCancelButton: true + }); + if (canceled) return; + + const id = uuid(); + this.content.push({ id, type }); + }, + + async addVariable() { + let { canceled, result: name } = await os.dialog({ + title: this.$ts._pages.enterVariableName, + input: { + type: 'text', + }, + showCancelButton: true + }); + if (canceled) return; + + name = name.trim(); + + if (this.hpml.isUsedName(name)) { + os.dialog({ + type: 'error', + text: this.$ts._pages.variableNameIsAlreadyUsed + }); + return; + } + + const id = uuid(); + this.variables.push({ id, name, type: null }); + }, + + removeVariable(v) { + this.variables = this.variables.filter(x => x.name !== v.name); + }, + + getPageBlockList() { + return [{ + label: this.$ts._pages.contentBlocks, + items: [ + { value: 'section', text: this.$ts._pages.blocks.section }, + { value: 'text', text: this.$ts._pages.blocks.text }, + { value: 'image', text: this.$ts._pages.blocks.image }, + { value: 'textarea', text: this.$ts._pages.blocks.textarea }, + { value: 'note', text: this.$ts._pages.blocks.note }, + { value: 'canvas', text: this.$ts._pages.blocks.canvas }, + ] + }, { + label: this.$ts._pages.inputBlocks, + items: [ + { value: 'button', text: this.$ts._pages.blocks.button }, + { value: 'radioButton', text: this.$ts._pages.blocks.radioButton }, + { value: 'textInput', text: this.$ts._pages.blocks.textInput }, + { value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput }, + { value: 'numberInput', text: this.$ts._pages.blocks.numberInput }, + { value: 'switch', text: this.$ts._pages.blocks.switch }, + { value: 'counter', text: this.$ts._pages.blocks.counter } + ] + }, { + label: this.$ts._pages.specialBlocks, + items: [ + { value: 'if', text: this.$ts._pages.blocks.if }, + { value: 'post', text: this.$ts._pages.blocks.post } + ] + }]; + }, + + getScriptBlockList(type: string = null) { + const list = []; + + const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number'); + + for (const block of blocks) { + const category = list.find(x => x.category === block.category); + if (category) { + category.items.push({ + value: block.type, + text: this.$t(`_pages.script.blocks.${block.type}`) + }); + } else { + list.push({ + category: block.category, + label: this.$t(`_pages.script.categories.${block.category}`), + items: [{ + value: block.type, + text: this.$t(`_pages.script.blocks.${block.type}`) + }] + }); + } + } + + const userFns = this.variables.filter(x => x.type === 'fn'); + if (userFns.length > 0) { + list.unshift({ + label: this.$t(`_pages.script.categories.fn`), + items: userFns.map(v => ({ + value: 'fn:' + v.name, + text: v.name + })) + }); + } + + return list; + }, + + setEyeCatchingImage(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + this.eyeCatchingImageId = file.id; + }); + }, + + removeEyeCatchingImage() { + this.eyeCatchingImageId = null; + }, + + highlighter(code) { + return highlight(code, languages.js, 'javascript'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.jqqmcavi { + > .button { + & + .button { + margin-left: 8px; + } + } +} + +.gwbmwxkm { + position: relative; + + > header { + > .title { + z-index: 1; + margin: 0; + padding: 0 16px; + line-height: 42px; + font-size: 0.9em; + font-weight: bold; + box-shadow: 0 1px rgba(#000, 0.07); + + > i { + margin-right: 6px; + } + + &:empty { + display: none; + } + } + + > .buttons { + position: absolute; + z-index: 2; + top: 0; + right: 0; + + > button { + padding: 0; + width: 42px; + font-size: 0.9em; + line-height: 42px; + } + } + } + + > section { + padding: 0 32px 32px 32px; + + @media (max-width: 500px) { + padding: 0 16px 16px 16px; + } + + > .view { + display: inline-block; + margin: 16px 0 0 0; + font-size: 14px; + } + + > .content { + margin-bottom: 16px; + } + + > .eyeCatch { + margin-bottom: 16px; + + > div { + > img { + max-width: 100%; + } + } + } + } +} + +.qmuvgica { + padding: 16px; + + > .variables { + margin-bottom: 16px; + } + + > .add { + margin-bottom: 16px; + } +} +</style> diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue new file mode 100644 index 0000000000..1eff1a98cb --- /dev/null +++ b/packages/client/src/pages/page.vue @@ -0,0 +1,311 @@ +<template> +<div> + <transition name="fade" mode="out-in"> + <div v-if="page" class="xcukqgmh" :key="page.id" v-size="{ max: [450] }"> + <div class="_block main"> + <!-- + <div class="header"> + <h1>{{ page.title }}</h1> + </div> + --> + <div class="banner"> + <img :src="page.eyeCatchingImage.url" v-if="page.eyeCatchingImageId"/> + </div> + <div class="content"> + <XPage :page="page"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton class="button" @click="unlike()" v-if="page.isLiked" v-tooltip="$ts._pages.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton> + <MkButton class="button" @click="like()" v-else v-tooltip="$ts._pages.like"><i class="far fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button> + <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="page.user" class="avatar"/> + <div class="name"> + <MkUserName :user="page.user" style="display: block;"/> + <MkAcct :user="page.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + <div class="links"> + <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA> + <template v-if="$i && $i.id === page.userId"> + <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA> + <button v-if="$i.pinnedPageId === page.id" @click="pin(false)" class="link _textButton">{{ $ts.unpin }}</button> + <button v-else @click="pin(true)" class="link _textButton">{{ $ts.pin }}</button> + </template> + </div> + </div> + <div class="footer"> + <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> + </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <MkPagination :pagination="otherPostsPagination" #default="{items}"> + <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/> + </MkPagination> + </MkContainer> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import XPage from '@/components/page/page.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { url } from '@/config'; +import MkFollowButton from '@/components/follow-button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkPagePreview from '@/components/page-preview.vue'; + +export default defineComponent({ + components: { + XPage, + MkButton, + MkFollowButton, + MkContainer, + MkPagination, + MkPagePreview, + }, + + props: { + pageName: { + type: String, + required: true + }, + username: { + type: String, + required: true + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.page ? { + title: computed(() => this.page.title || this.page.name), + avatar: this.page.user, + path: `/@${this.page.user.username}/pages/${this.page.name}`, + share: { + title: this.page.title || this.page.name, + text: this.page.summary, + }, + } : null), + page: null, + error: null, + otherPostsPagination: { + endpoint: 'users/pages', + limit: 6, + params: () => ({ + userId: this.page.user.id + }) + }, + }; + }, + + computed: { + path(): string { + return this.username + '/' + this.pageName; + } + }, + + watch: { + path() { + this.fetch(); + } + }, + + created() { + this.fetch(); + }, + + methods: { + fetch() { + this.page = null; + os.api('pages/show', { + name: this.pageName, + username: this.username, + }).then(page => { + this.page = page; + }).catch(e => { + this.error = e; + }); + }, + + share() { + navigator.share({ + title: this.page.title || this.page.name, + text: this.page.summary, + url: `${url}/@${this.page.user.username}/pages/${this.page.name}` + }); + }, + + shareWithNote() { + os.post({ + initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}` + }); + }, + + like() { + os.apiWithDialog('pages/like', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = true; + this.page.likedCount++; + }); + }, + + async unlike() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('pages/unlike', { + pageId: this.page.id, + }).then(() => { + this.page.isLiked = false; + this.page.likedCount--; + }); + }, + + pin(pin) { + os.apiWithDialog('i/update', { + pinnedPageId: pin ? this.page.id : null, + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.xcukqgmh { + --padding: 32px; + + &.max-width_450px { + --padding: 16px; + } + + > .main { + padding: var(--padding); + + > .header { + padding: 16px; + + > h1 { + margin: 0; + } + } + + > .banner { + > img { + // TODO: 良い感じのアスペクト比で表示 + display: block; + width: 100%; + height: 150px; + object-fit: cover; + } + } + + > .content { + margin-top: 16px; + padding: 16px 0 0 0; + } + + > .actions { + display: flex; + align-items: center; + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .like { + > .button { + --accent: rgb(241 97 132); + --X8: rgb(241 92 128); + --buttonBg: rgb(216 71 106 / 5%); + --buttonHoverBg: rgb(216 71 106 / 10%); + color: #ff002f; + + ::v-deep(.count) { + margin-left: 0.5em; + } + } + } + + > .other { + margin-left: auto; + + > button { + padding: 8px; + margin: 0 8px; + + &:hover { + color: var(--fgHighlighted); + } + } + } + } + + > .user { + margin-top: 16px; + padding: 16px 0 0 0; + border-top: solid 0.5px var(--divider); + display: flex; + align-items: center; + + > .avatar { + width: 52px; + height: 52px; + } + + > .name { + margin: 0 0 0 12px; + font-size: 90%; + } + + > .koudoku { + margin-left: auto; + } + } + + > .links { + margin-top: 16px; + padding: 24px 0 0 0; + border-top: solid 0.5px var(--divider); + + > .link { + margin-right: 0.75em; + } + } + } + + > .footer { + margin: var(--padding); + font-size: 85%; + opacity: 0.75; + } +} +</style> diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue new file mode 100644 index 0000000000..d66fc2ad5b --- /dev/null +++ b/packages/client/src/pages/pages.vue @@ -0,0 +1,96 @@ +<template> +<MkSpacer> + <!-- TODO: MkHeaderに統合 --> + <MkTab v-model="tab" v-if="$i"> + <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._pages.featured }}</option> + <option value="my"><i class="fas fa-edit"></i> {{ $ts._pages.my }}</option> + <option value="liked"><i class="fas fa-heart"></i> {{ $ts._pages.liked }}</option> + </MkTab> + + <div class="_section"> + <div class="rknalgpo _content" v-if="tab === 'featured'"> + <MkPagination :pagination="featuredPagesPagination" #default="{items}"> + <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </MkPagination> + </div> + + <div class="rknalgpo _content my" v-if="tab === 'my'"> + <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> + <MkPagination :pagination="myPagesPagination" #default="{items}"> + <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/> + </MkPagination> + </div> + + <div class="rknalgpo _content" v-if="tab === 'liked'"> + <MkPagination :pagination="likedPagesPagination" #default="{items}"> + <MkPagePreview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/> + </MkPagination> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagePreview from '@/components/page-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTab from '@/components/tab.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagePreview, MkPagination, MkButton, MkTab + }, + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.pages, + icon: 'fas fa-sticky-note', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-plus', + text: this.$ts.create, + handler: this.create, + }], + }, + tab: 'featured', + featuredPagesPagination: { + endpoint: 'pages/featured', + noPaging: true, + }, + myPagesPagination: { + endpoint: 'i/pages', + limit: 5, + }, + likedPagesPagination: { + endpoint: 'i/page-likes', + limit: 5, + }, + }; + }, + methods: { + create() { + this.$router.push(`/pages/new`); + } + } +}); +</script> + +<style lang="scss" scoped> +.rknalgpo { + &.my .ckltabjg:first-child { + margin-top: 16px; + } + + .ckltabjg:not(:last-child) { + margin-bottom: 8px; + } + + @media (min-width: 500px) { + .ckltabjg:not(:last-child) { + margin-bottom: 16px; + } + } +} +</style> diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue new file mode 100644 index 0000000000..9d1ebb74ed --- /dev/null +++ b/packages/client/src/pages/preview.vue @@ -0,0 +1,32 @@ +<template> +<div class="graojtoi"> + <MkSample/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkSample from '@/components/sample.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkSample, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.preview, + icon: 'fas fa-eye', + }, + } + }, +}); +</script> + +<style lang="scss" scoped> +.graojtoi { + padding: var(--margin); +} +</style> diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue new file mode 100644 index 0000000000..f9a2500840 --- /dev/null +++ b/packages/client/src/pages/reset-password.vue @@ -0,0 +1,69 @@ +<template> +<FormBase v-if="token"> + <FormInput v-model="password" type="password"> + <template #prefix><i class="fas fa-lock"></i></template> + <span>{{ $ts.newPassword }}</span> + </FormInput> + + <FormButton primary @click="save">{{ $ts.save }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormGroup, + FormLink, + FormInput, + FormButton, + }, + + props: { + token: { + type: String, + required: false + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.resetPassword, + icon: 'fas fa-lock' + }, + password: '', + } + }, + + mounted() { + if (this.token == null) { + os.popup(import('@/components/forgot-password.vue'), {}, {}, 'closed'); + this.$router.push('/'); + } + }, + + methods: { + async save() { + await os.apiWithDialog('reset-password', { + token: this.token, + password: this.password, + }); + this.$router.push('/'); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/reversi/game.board.vue b/packages/client/src/pages/reversi/game.board.vue new file mode 100644 index 0000000000..529e00d969 --- /dev/null +++ b/packages/client/src/pages/reversi/game.board.vue @@ -0,0 +1,528 @@ +<template> +<div class="xqnhankfuuilcwvhgsopeqncafzsquya"> + <header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ $ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ $ts._reversi.white }})</header> + + <div style="overflow: hidden; line-height: 28px;"> + <p class="turn" v-if="!iAmPlayer && !game.isEnded"> + <Mfm :key="'turn:' + turnUser().name" :text="$t('_reversi.turnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/> + <MkEllipsis/> + </p> + <p class="turn" v-if="logPos != logs.length"> + <Mfm :key="'past-turn-of:' + turnUser().name" :text="$t('_reversi.pastTurnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/> + </p> + <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn()">{{ $ts._reversi.opponentTurn }}<MkEllipsis/></p> + <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn()" style="animation: tada 1s linear infinite both;">{{ $ts._reversi.myTurn }}</p> + <p class="result" v-if="game.isEnded && logPos == logs.length"> + <template v-if="game.winner"> + <Mfm :key="'won'" :text="$t('_reversi.won', { name: game.winner.name })" :plain="true" :custom-emojis="game.winner.emojis"/> + <span v-if="game.surrendered != null"> ({{ $ts._reversi.surrendered }})</span> + </template> + <template v-else>{{ $ts._reversi.drawn }}</template> + </p> + </div> + + <div class="board"> + <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels"> + <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> + </div> + <div class="flex"> + <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels"> + <div v-for="i in game.map.length">{{ i }}</div> + </div> + <div class="cells" :style="cellsStyle"> + <div v-for="(stone, i) in o.board" + :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn(), can: turnUser() ? o.canPut(turnUser().id == blackUser.id, i) : null, prev: o.prevPos == i }" + @click="set(i)" + :title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`" + > + <template v-if="$store.state.gamesReversiUseAvatarStones || true"> + <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black"> + <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white"> + </template> + <template v-else> + <i v-if="stone === true" class="fas fa-circle"></i> + <i v-if="stone === false" class="far fa-circle"></i> + </template> + </div> + </div> + <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels"> + <div v-for="i in game.map.length">{{ i }}</div> + </div> + </div> + <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels"> + <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span> + </div> + </div> + + <p class="status"><b>{{ $t('_reversi.turnCount', { count: logPos }) }}</b> {{ $ts._reversi.black }}:{{ o.blackCount }} {{ $ts._reversi.white }}:{{ o.whiteCount }} {{ $ts._reversi.total }}:{{ o.blackCount + o.whiteCount }}</p> + + <div class="actions" v-if="!game.isEnded && iAmPlayer"> + <MkButton @click="surrender" inline>{{ $ts._reversi.surrender }}</MkButton> + </div> + + <div class="player" v-if="game.isEnded"> + <span>{{ logPos }} / {{ logs.length }}</span> + <div class="buttons" v-if="!autoplaying"> + <MkButton inline @click="logPos = 0" :disabled="logPos == 0"><i class="fas fa-angle-double-left"></i></MkButton> + <MkButton inline @click="logPos--" :disabled="logPos == 0"><i class="fas fa-angle-left"></i></MkButton> + <MkButton inline @click="logPos++" :disabled="logPos == logs.length"><i class="fas fa-angle-right"></i></MkButton> + <MkButton inline @click="logPos = logs.length" :disabled="logPos == logs.length"><i class="fas fa-angle-double-right"></i></MkButton> + </div> + <MkButton @click="autoplay()" :disabled="autoplaying" style="margin: var(--margin) auto 0 auto;"><i class="fas fa-play"></i></MkButton> + </div> + + <div class="info"> + <p v-if="game.isLlotheo">{{ $ts._reversi.isLlotheo }}</p> + <p v-if="game.loopedBoard">{{ $ts._reversi.loopedMap }}</p> + <p v-if="game.canPutEverywhere">{{ $ts._reversi.canPutEverywhere }}</p> + </div> + + <div class="watchers"> + <MkAvatar v-for="user in watchers" :key="user.id" :user="user" class="avatar"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as CRC32 from 'crc-32'; +import Reversi, { Color } from '@/scripts/games/reversi/core'; +import { url } from '@/config'; +import MkButton from '@/components/ui/button.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; + +export default defineComponent({ + components: { + MkButton + }, + + props: { + initGame: { + type: Object, + require: true + }, + connection: { + type: Object, + require: true + }, + }, + + data() { + return { + game: JSON.parse(JSON.stringify(this.initGame)), + o: null as Reversi, + logs: [], + logPos: 0, + watchers: [], + pollingClock: null, + }; + }, + + computed: { + iAmPlayer(): boolean { + if (!this.$i) return false; + return this.game.user1Id == this.$i.id || this.game.user2Id == this.$i.id; + }, + + myColor(): Color { + if (!this.iAmPlayer) return null; + if (this.game.user1Id == this.$i.id && this.game.black == 1) return true; + if (this.game.user2Id == this.$i.id && this.game.black == 2) return true; + return false; + }, + + opColor(): Color { + if (!this.iAmPlayer) return null; + return this.myColor === true ? false : true; + }, + + blackUser(): any { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + }, + + whiteUser(): any { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + }, + + cellsStyle(): any { + return { + 'grid-template-rows': `repeat(${this.game.map.length}, 1fr)`, + 'grid-template-columns': `repeat(${this.game.map[0].length}, 1fr)` + }; + } + }, + + watch: { + logPos(v) { + if (!this.game.isEnded) return; + const o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + for (const log of this.logs.slice(0, v)) { + o.put(log.color, log.pos); + } + this.o = o; + //this.$forceUpdate(); + } + }, + + created() { + this.o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + + for (const log of this.game.logs) { + this.o.put(log.color, log.pos); + } + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + // 通信を取りこぼしてもいいように定期的にポーリングさせる + if (this.game.isStarted && !this.game.isEnded) { + this.pollingClock = setInterval(() => { + if (this.game.isEnded) return; + const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join('')); + this.connection.send('check', { + crc32: crc32 + }); + }, 3000); + } + }, + + mounted() { + this.connection.on('set', this.onSet); + this.connection.on('rescue', this.onRescue); + this.connection.on('ended', this.onEnded); + this.connection.on('watchers', this.onWatchers); + }, + + beforeUnmount() { + this.connection.off('set', this.onSet); + this.connection.off('rescue', this.onRescue); + this.connection.off('ended', this.onEnded); + this.connection.off('watchers', this.onWatchers); + + clearInterval(this.pollingClock); + }, + + methods: { + userPage, + + // this.o がリアクティブになった折にはcomputedにできる + turnUser(): any { + if (this.o.turn === true) { + return this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.turn === false) { + return this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + return null; + } + }, + + // this.o がリアクティブになった折にはcomputedにできる + isMyTurn(): boolean { + if (!this.iAmPlayer) return false; + if (this.turnUser() == null) return false; + return this.turnUser().id == this.$i.id; + }, + + set(pos) { + if (this.game.isEnded) return; + if (!this.iAmPlayer) return; + if (!this.isMyTurn()) return; + if (!this.o.canPut(this.myColor, pos)) return; + + this.o.put(this.myColor, pos); + + // サウンドを再生する + sound.play(this.myColor ? 'reversiPutBlack' : 'reversiPutWhite'); + + this.connection.send('set', { + pos: pos + }); + + this.checkEnd(); + + this.$forceUpdate(); + }, + + onSet(x) { + this.logs.push(x); + this.logPos++; + this.o.put(x.color, x.pos); + this.checkEnd(); + this.$forceUpdate(); + + // サウンドを再生する + if (x.color !== this.myColor) { + sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite'); + } + }, + + onEnded(x) { + this.game = JSON.parse(JSON.stringify(x.game)); + }, + + checkEnd() { + this.game.isEnded = this.o.isEnded; + if (this.game.isEnded) { + if (this.o.winner === true) { + this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id; + this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2; + } else if (this.o.winner === false) { + this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id; + this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1; + } else { + this.game.winnerId = null; + this.game.winner = null; + } + } + }, + + // 正しいゲーム情報が送られてきたとき + onRescue(game) { + this.game = JSON.parse(JSON.stringify(game)); + + this.o = new Reversi(this.game.map, { + isLlotheo: this.game.isLlotheo, + canPutEverywhere: this.game.canPutEverywhere, + loopedBoard: this.game.loopedBoard + }); + + for (const log of this.game.logs) { + this.o.put(log.color, log.pos, true); + } + + this.logs = this.game.logs; + this.logPos = this.logs.length; + + this.checkEnd(); + this.$forceUpdate(); + }, + + onWatchers(users) { + this.watchers = users; + }, + + surrender() { + os.api('games/reversi/games/surrender', { + gameId: this.game.id + }); + }, + + autoplay() { + this.autoplaying = true; + this.logPos = 0; + + setTimeout(() => { + this.logPos = 1; + + let i = 1; + let previousLog = this.game.logs[0]; + const tick = () => { + const log = this.game.logs[i]; + const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime() + setTimeout(() => { + i++; + this.logPos++; + previousLog = log; + + if (i < this.game.logs.length) { + tick(); + } else { + this.autoplaying = false; + } + }, time); + }; + + tick(); + }, 1000); + } + } +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.xqnhankfuuilcwvhgsopeqncafzsquya { + text-align: center; + + > .go-index { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 42px; + height :42px; + } + + > header { + padding: 8px; + border-bottom: dashed 1px var(--divider); + } + + > .board { + width: calc(100% - 16px); + max-width: 500px; + margin: 0 auto; + + $label-size: 16px; + $gap: 4px; + + > .labels-x { + height: $label-size; + padding: 0 $label-size; + display: flex; + + > * { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8em; + + &:first-child { + margin-left: -(math.div($gap, 2)); + } + + &:last-child { + margin-right: -(math.div($gap, 2)); + } + } + } + + > .flex { + display: flex; + + > .labels-y { + width: $label-size; + display: flex; + flex-direction: column; + + > * { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + + &:first-child { + margin-top: -(math.div($gap, 2)); + } + + &:last-child { + margin-bottom: -(math.div($gap, 2)); + } + } + } + + > .cells { + flex: 1; + display: grid; + grid-gap: $gap; + + > div { + background: transparent; + border-radius: 6px; + overflow: hidden; + + * { + pointer-events: none; + user-select: none; + } + + &.empty { + border: solid 2px var(--divider); + } + + &.empty.can { + border-color: var(--accent); + } + + &.empty.myTurn { + border-color: var(--divider); + + &.can { + border-color: var(--accent); + cursor: pointer; + + &:hover { + background: var(--accent); + } + } + } + + &.prev { + box-shadow: 0 0 0 4px var(--accent); + } + + &.isEnded { + border-color: var(--divider); + } + + &.none { + border-color: transparent !important; + } + + > svg, > img { + display: block; + width: 100%; + height: 100%; + } + } + } + } + } + + > .status { + margin: 0; + padding: 16px 0; + } + + > .actions { + padding-bottom: 16px; + } + + > .player { + padding: 0 16px 32px 16px; + margin: 0 auto; + max-width: 500px; + + > span { + display: inline-block; + margin: 0 8px; + min-width: 70px; + } + + > .buttons { + display: flex; + + > * { + flex: 1; + } + } + } + + > .watchers { + padding: 0 0 16px 0; + + &:empty { + display: none; + } + + > .avatar { + width: 32px; + height: 32px; + } + } +} +</style> diff --git a/packages/client/src/pages/reversi/game.setting.vue b/packages/client/src/pages/reversi/game.setting.vue new file mode 100644 index 0000000000..e6a6661f16 --- /dev/null +++ b/packages/client/src/pages/reversi/game.setting.vue @@ -0,0 +1,390 @@ +<template> +<div class="urbixznjwwuukfsckrwzwsqzsxornqij"> + <header><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></header> + + <div> + <p>{{ $ts._reversi.gameSettings }}</p> + + <div class="card map _panel"> + <header> + <select v-model="mapName" :placeholder="$ts._reversi.chooseBoard" @change="onMapChange"> + <option label="-Custom-" :value="mapName" v-if="mapName == '-Custom-'"/> + <option :label="$ts.random" :value="null"/> + <optgroup v-for="c in mapCategories" :key="c" :label="c"> + <option v-for="m in Object.values(maps).filter(m => m.category == c)" :key="m.name" :label="m.name" :value="m.name">{{ m.name }}</option> + </optgroup> + </select> + </header> + + <div> + <div class="random" v-if="game.map == null"><i class="fas fa-dice"></i></div> + <div class="board" v-else :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }"> + <div v-for="(x, i) in game.map.join('')" :class="{ none: x == ' ' }" @click="onPixelClick(i, x)"> + <i v-if="x === 'b'" class="fas fa-circle"></i> + <i v-if="x === 'w'" class="far fa-circle"></i> + </div> + </div> + </div> + </div> + + <div class="card _panel"> + <header> + <span>{{ $ts._reversi.blackOrWhite }}</span> + </header> + + <div> + <MkRadio v-model="game.bw" value="random" @update:modelValue="updateSettings('bw')">{{ $ts.random }}</MkRadio> + <MkRadio v-model="game.bw" :value="'1'" @update:modelValue="updateSettings('bw')"> + <I18n :src="$ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user1"/></b> + </template> + </I18n> + </MkRadio> + <MkRadio v-model="game.bw" :value="'2'" @update:modelValue="updateSettings('bw')"> + <I18n :src="$ts._reversi.blackIs" tag="span"> + <template #name> + <b><MkUserName :user="game.user2"/></b> + </template> + </I18n> + </MkRadio> + </div> + </div> + + <div class="card _panel"> + <header> + <span>{{ $ts._reversi.rules }}</span> + </header> + + <div> + <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ $ts._reversi.isLlotheo }}</MkSwitch> + <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ $ts._reversi.loopedMap }}</MkSwitch> + <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ $ts._reversi.canPutEverywhere }}</MkSwitch> + </div> + </div> + + <div class="card form _panel" v-if="form"> + <header> + <span>{{ $ts._reversi.botSettings }}</span> + </header> + + <div> + <template v-for="item in form"> + <MkSwitch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</MkSwitch> + + <div class="card" v-if="item.type == 'radio'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <MkRadio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :value="r.value" @update:modelValue="onChangeForm(item)">{{ r.label }}</MkRadio> + </div> + </div> + + <div class="card" v-if="item.type == 'slider'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <input type="range" :min="item.min" :max="item.max" :step="item.step || 1" v-model="item.value" @change="onChangeForm(item)"/> + </div> + </div> + + <div class="card" v-if="item.type == 'textbox'" :key="item.id"> + <header> + <span>{{ item.label }}</span> + </header> + + <div> + <input v-model="item.value" @change="onChangeForm(item)"/> + </div> + </div> + </template> + </div> + </div> + </div> + + <footer class="_acrylic"> + <p class="status"> + <template v-if="isAccepted && isOpAccepted">{{ $ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template> + <template v-if="isAccepted && !isOpAccepted">{{ $ts._reversi.waitingForOther }}<MkEllipsis/></template> + <template v-if="!isAccepted && isOpAccepted">{{ $ts._reversi.waitingForMe }}</template> + <template v-if="!isAccepted && !isOpAccepted">{{ $ts._reversi.waitingBoth }}<MkEllipsis/></template> + </p> + + <div class="actions"> + <MkButton inline @click="exit">{{ $ts.cancel }}</MkButton> + <MkButton inline primary @click="accept" v-if="!isAccepted">{{ $ts._reversi.ready }}</MkButton> + <MkButton inline primary @click="cancel" v-if="isAccepted">{{ $ts._reversi.cancelReady }}</MkButton> + </div> + </footer> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as maps from '@/scripts/games/reversi/maps'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkRadio from '@/components/form/radio.vue'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + MkRadio, + }, + + props: { + initGame: { + type: Object, + require: true + }, + connection: { + type: Object, + require: true + }, + }, + + data() { + return { + game: this.initGame, + o: null, + isLlotheo: false, + mapName: maps.eighteight.name, + maps: maps, + form: null, + messages: [], + }; + }, + + computed: { + mapCategories(): string[] { + const categories = Object.values(maps).map(x => x.category); + return categories.filter((item, pos) => categories.indexOf(item) == pos); + }, + isAccepted(): boolean { + if (this.game.user1Id == this.$i.id && this.game.user1Accepted) return true; + if (this.game.user2Id == this.$i.id && this.game.user2Accepted) return true; + return false; + }, + isOpAccepted(): boolean { + if (this.game.user1Id != this.$i.id && this.game.user1Accepted) return true; + if (this.game.user2Id != this.$i.id && this.game.user2Accepted) return true; + return false; + } + }, + + created() { + this.connection.on('changeAccepts', this.onChangeAccepts); + this.connection.on('updateSettings', this.onUpdateSettings); + this.connection.on('initForm', this.onInitForm); + this.connection.on('message', this.onMessage); + + if (this.game.user1Id != this.$i.id && this.game.form1) this.form = this.game.form1; + if (this.game.user2Id != this.$i.id && this.game.form2) this.form = this.game.form2; + }, + + beforeUnmount() { + this.connection.off('changeAccepts', this.onChangeAccepts); + this.connection.off('updateSettings', this.onUpdateSettings); + this.connection.off('initForm', this.onInitForm); + this.connection.off('message', this.onMessage); + }, + + methods: { + exit() { + + }, + + accept() { + this.connection.send('accept', {}); + }, + + cancel() { + this.connection.send('cancelAccept', {}); + }, + + onChangeAccepts(accepts) { + this.game.user1Accepted = accepts.user1; + this.game.user2Accepted = accepts.user2; + }, + + updateSettings(key: string) { + this.connection.send('updateSettings', { + key: key, + value: this.game[key] + }); + }, + + onUpdateSettings({ key, value }) { + this.game[key] = value; + if (this.game.map == null) { + this.mapName = null; + } else { + const found = Object.values(maps).find(x => x.data.join('') == this.game.map.join('')); + this.mapName = found ? found.name : '-Custom-'; + } + }, + + onInitForm(x) { + if (x.userId == this.$i.id) return; + this.form = x.form; + }, + + onMessage(x) { + if (x.userId == this.$i.id) return; + this.messages.unshift(x.message); + }, + + onChangeForm(item) { + this.connection.send('updateForm', { + id: item.id, + value: item.value + }); + }, + + onMapChange() { + if (this.mapName == null) { + this.game.map = null; + } else { + this.game.map = Object.values(maps).find(x => x.name == this.mapName).data; + } + this.updateSettings('map'); + }, + + onPixelClick(pos, pixel) { + const x = pos % this.game.map[0].length; + const y = Math.floor(pos / this.game.map[0].length); + const newPixel = + pixel == ' ' ? '-' : + pixel == '-' ? 'b' : + pixel == 'b' ? 'w' : + ' '; + const line = this.game.map[y].split(''); + line[x] = newPixel; + this.game.map[y] = line.join(''); + this.updateSettings('map'); + } + } +}); +</script> + +<style lang="scss" scoped> +.urbixznjwwuukfsckrwzwsqzsxornqij { + text-align: center; + background: var(--bg); + + > header { + padding: 8px; + border-bottom: dashed 1px #c4cdd4; + } + + > div { + padding: 0 16px; + + > .card { + margin: 0 auto 16px auto; + + &.map { + > header { + > select { + width: 100%; + padding: 12px 14px; + background: var(--face); + border: 1px solid var(--inputBorder); + border-radius: 4px; + color: var(--fg); + cursor: pointer; + transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + &:focus-visible, + &:active { + border-color: var(--accent); + } + } + } + + > div { + > .random { + padding: 32px 0; + font-size: 64px; + color: var(--fg); + opacity: 0.7; + } + + > .board { + display: grid; + grid-gap: 4px; + width: 300px; + height: 300px; + margin: 0 auto; + color: var(--fg); + + > div { + background: transparent; + border: solid 2px var(--divider); + border-radius: 6px; + overflow: hidden; + cursor: pointer; + + * { + pointer-events: none; + user-select: none; + width: 100%; + height: 100%; + } + + &.none { + border-color: transparent; + } + } + } + } + } + + &.form { + > div { + > .card + .card { + margin-top: 16px; + } + + input[type='range'] { + width: 100%; + } + } + } + } + + .card { + max-width: 400px; + + > header { + padding: 18px 20px; + border-bottom: 1px solid var(--divider); + } + + > div { + padding: 20px; + color: var(--fg); + } + } + } + + > footer { + position: sticky; + bottom: 0; + padding: 16px; + border-top: solid 1px var(--divider); + + > .status { + margin: 0 0 16px 0; + } + } +} +</style> diff --git a/packages/client/src/pages/reversi/game.vue b/packages/client/src/pages/reversi/game.vue new file mode 100644 index 0000000000..b1ed632904 --- /dev/null +++ b/packages/client/src/pages/reversi/game.vue @@ -0,0 +1,76 @@ +<template> +<div v-if="game == null"><MkLoading/></div> +<GameSetting v-else-if="!game.isStarted" :init-game="game" :connection="connection"/> +<GameBoard v-else :init-game="game" :connection="connection"/> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import GameSetting from './game.setting.vue'; +import GameBoard from './game.board.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + GameSetting, + GameBoard, + }, + + props: { + gameId: { + type: String, + required: true + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._reversi.reversi, + icon: 'fas fa-gamepad' + }, + game: null, + connection: null, + }; + }, + + watch: { + gameId() { + this.fetch(); + } + }, + + mounted() { + this.fetch(); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.dispose(); + } + }, + + methods: { + fetch() { + os.api('games/reversi/games/show', { + gameId: this.gameId + }).then(game => { + this.game = game; + + if (this.connection) { + this.connection.dispose(); + } + this.connection = markRaw(os.stream.useChannel('gamesReversiGame', { + gameId: this.game.id + })); + this.connection.on('started', this.onStarted); + }); + }, + + onStarted(game) { + Object.assign(this.game, game); + }, + } +}); +</script> diff --git a/packages/client/src/pages/reversi/index.vue b/packages/client/src/pages/reversi/index.vue new file mode 100644 index 0000000000..1b8f1ffb71 --- /dev/null +++ b/packages/client/src/pages/reversi/index.vue @@ -0,0 +1,279 @@ +<template> +<div class="bgvwxkhb" v-if="!matching"> + <h1>Misskey {{ $ts._reversi.reversi }}</h1> + + <div class="play"> + <MkButton primary round @click="match" style="margin: var(--margin) auto 0 auto;">{{ $ts.invite }}</MkButton> + </div> + + <div class="_section"> + <MkFolder v-if="invitations.length > 0"> + <template #header>{{ $ts.invitations }}</template> + <div class="nfcacttm"> + <button class="invitation _panel _button" v-for="invitation in invitations" tabindex="-1" @click="accept(invitation)"> + <MkAvatar class="avatar" :user="invitation.parent" :show-indicator="true"/> + <span class="name"><b><MkUserName :user="invitation.parent"/></b></span> + <span class="username">@{{ invitation.parent.username }}</span> + <MkTime :time="invitation.createdAt" class="time"/> + </button> + </div> + </MkFolder> + + <MkFolder v-if="myGames.length > 0"> + <template #header>{{ $ts._reversi.myGames }}</template> + <div class="knextgwz"> + <MkA class="game _panel" v-for="g in myGames" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id"> + <div class="players"> + <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/> + </div> + <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer> + </MkA> + </div> + </MkFolder> + + <MkFolder v-if="games.length > 0"> + <template #header>{{ $ts._reversi.allGames }}</template> + <div class="knextgwz"> + <MkA class="game _panel" v-for="g in games" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id"> + <div class="players"> + <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/> + </div> + <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer> + </MkA> + </div> + </MkFolder> + </div> +</div> +<div class="sazhgisb" v-else> + <h1> + <I18n :src="$ts.waitingFor" tag="span"> + <template #x> + <b><MkUserName :user="matching"/></b> + </template> + </I18n> + <MkEllipsis/> + </h1> + <div class="cancel"> + <MkButton inline round @click="cancel">{{ $ts.cancel }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import * as os from '@/os'; +import MkButton from '@/components/ui/button.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, MkFolder, + }, + + inject: ['navHook'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._reversi.reversi, + icon: 'fas fa-gamepad' + }, + games: [], + gamesFetching: true, + gamesMoreFetching: false, + myGames: [], + matching: null, + invitations: [], + connection: null, + pingClock: null, + }; + }, + + mounted() { + if (this.$i) { + this.connection = markRaw(os.stream.useChannel('gamesReversi')); + + this.connection.on('invited', this.onInvited); + + this.connection.on('matched', this.onMatched); + + this.pingClock = setInterval(() => { + if (this.matching) { + this.connection.send('ping', { + id: this.matching.id + }); + } + }, 3000); + + os.api('games/reversi/games', { + my: true + }).then(games => { + this.myGames = games; + }); + + os.api('games/reversi/invitations').then(invitations => { + this.invitations = this.invitations.concat(invitations); + }); + } + + os.api('games/reversi/games').then(games => { + this.games = games; + this.gamesFetching = false; + }); + }, + + beforeUnmount() { + if (this.connection) { + this.connection.dispose(); + clearInterval(this.pingClock); + } + }, + + methods: { + go(game) { + const url = '/games/reversi/' + game.id; + if (this.navHook) { + this.navHook(url); + } else { + this.$router.push(url); + } + }, + + async match() { + const user = await os.selectUser({ local: true }); + if (user == null) return; + os.api('games/reversi/match', { + userId: user.id + }).then(res => { + if (res == null) { + this.matching = user; + } else { + this.go(res); + } + }); + }, + + cancel() { + this.matching = null; + os.api('games/reversi/match/cancel'); + }, + + accept(invitation) { + os.api('games/reversi/match', { + userId: invitation.parent.id + }).then(game => { + if (game) { + this.go(game); + } + }); + }, + + onMatched(game) { + this.go(game); + }, + + onInvited(invite) { + this.invitations.unshift(invite); + } + } +}); +</script> + +<style lang="scss" scoped> +.bgvwxkhb { + > h1 { + margin: 0; + padding: 24px; + text-align: center; + font-size: 1.5em; + background: linear-gradient(0deg, #43c583, #438881); + color: #fff; + } + + > .play { + text-align: center; + } +} + +.sazhgisb { + text-align: center; +} + +.nfcacttm { + > .invitation { + display: flex; + box-sizing: border-box; + width: 100%; + padding: 16px; + line-height: 32px; + text-align: left; + + > .avatar { + width: 32px; + height: 32px; + margin-right: 8px; + } + + > .name { + margin-right: 8px; + } + + > .username { + margin-right: 8px; + opacity: 0.7; + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} + +.knextgwz { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + + > .game { + > .players { + text-align: center; + padding: 16px; + line-height: 32px; + + > .avatar { + width: 32px; + height: 32px; + + &:first-child { + margin-right: 8px; + } + + &:last-child { + margin-left: 8px; + } + } + } + + > footer { + display: flex; + align-items: baseline; + border-top: solid 0.5px var(--divider); + padding: 6px 8px; + font-size: 0.9em; + + > .state { + &.playing { + color: var(--accent); + } + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } + } +} +</style> diff --git a/packages/client/src/pages/room/preview.vue b/packages/client/src/pages/room/preview.vue new file mode 100644 index 0000000000..b0e600d4fb --- /dev/null +++ b/packages/client/src/pages/room/preview.vue @@ -0,0 +1,107 @@ +<template> +<canvas width="224" height="128"></canvas> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as THREE from 'three'; +import * as os from '@/os'; + +export default defineComponent({ + data() { + return { + selected: null, + objectHeight: 0, + orbitRadius: 5 + }; + }, + + mounted() { + const canvas = this.$el; + + const width = canvas.width; + const height = canvas.height; + + const scene = new THREE.Scene(); + + const renderer = new THREE.WebGLRenderer({ + canvas: canvas, + antialias: true, + alpha: false + }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setSize(width, height); + renderer.setClearColor(0x000000); + renderer.autoClear = false; + renderer.shadowMap.enabled = true; + renderer.shadowMap.cullFace = THREE.CullFaceBack; + + const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100); + camera.zoom = 10; + camera.position.x = 0; + camera.position.y = 2; + camera.position.z = 0; + camera.updateProjectionMatrix(); + scene.add(camera); + + const ambientLight = new THREE.AmbientLight(0xffffff, 1); + ambientLight.castShadow = false; + scene.add(ambientLight); + + const light = new THREE.PointLight(0xffffff, 1, 100); + light.position.set(3, 3, 3); + scene.add(light); + + const grid = new THREE.GridHelper(5, 16, 0x444444, 0x222222); + scene.add(grid); + + const render = () => { + const timer = Date.now() * 0.0004; + requestAnimationFrame(render); + + camera.position.y = Math.sin(Math.PI / 6) * this.orbitRadius; // Math.PI / 6 => 30deg + camera.position.z = Math.cos(timer) * this.orbitRadius; + camera.position.x = Math.sin(timer) * this.orbitRadius; + camera.lookAt(new THREE.Vector3(0, this.objectHeight / 2, 0)); + renderer.render(scene, camera); + }; + + this.selected = selected => { + const obj = selected.clone(); + + // Remove current object + const current = scene.getObjectByName('obj'); + if (current != null) { + scene.remove(current); + } + + // Add new object + obj.name = 'obj'; + obj.position.x = 0; + obj.position.y = 0; + obj.position.z = 0; + obj.rotation.x = 0; + obj.rotation.y = 0; + obj.rotation.z = 0; + obj.traverse(child => { + if (child instanceof THREE.Mesh) { + child.material = child.material.clone(); + return child.material.emissive.setHex(0x000000); + } + }); + const objectBoundingBox = new THREE.Box3().setFromObject(obj); + this.objectHeight = objectBoundingBox.max.y - objectBoundingBox.min.y; + + const objectWidth = objectBoundingBox.max.x - objectBoundingBox.min.x; + const objectDepth = objectBoundingBox.max.z - objectBoundingBox.min.z; + + const horizontal = Math.hypot(objectWidth, objectDepth) / camera.aspect; + this.orbitRadius = Math.max(horizontal, this.objectHeight) * camera.zoom * 0.625 / Math.tan(camera.fov * 0.5 * (Math.PI / 180)); + + scene.add(obj); + }; + + render(); + }, +}); +</script> diff --git a/packages/client/src/pages/room/room.vue b/packages/client/src/pages/room/room.vue new file mode 100644 index 0000000000..1671bcd587 --- /dev/null +++ b/packages/client/src/pages/room/room.vue @@ -0,0 +1,285 @@ +<template> +<div class="hveuntkp"> + <div class="controller _section" v-if="objectSelected"> + <div class="_content"> + <p class="name">{{ selectedFurnitureName }}</p> + <XPreview ref="preview"/> + <template v-if="selectedFurnitureInfo.props"> + <div v-for="k in Object.keys(selectedFurnitureInfo.props)" :key="k"> + <p>{{ k }}</p> + <template v-if="selectedFurnitureInfo.props[k] === 'image'"> + <MkButton @click="chooseImage(k, $event)">{{ $ts._rooms.chooseImage }}</MkButton> + </template> + <template v-else-if="selectedFurnitureInfo.props[k] === 'color'"> + <input type="color" :value="selectedFurnitureProps ? selectedFurnitureProps[k] : null" @change="updateColor(k, $event)"/> + </template> + </div> + </template> + </div> + <div class="_content"> + <MkButton inline @click="translate()" :primary="isTranslateMode"><i class="fas fa-arrows-alt"></i> {{ $ts._rooms.translate }}</MkButton> + <MkButton inline @click="rotate()" :primary="isRotateMode"><i class="fas fa-undo"></i> {{ $ts._rooms.rotate }}</MkButton> + <MkButton inline v-if="isTranslateMode || isRotateMode" @click="exit()"><i class="fas fa-ban"></i> {{ $ts._rooms.exit }}</MkButton> + </div> + <div class="_content"> + <MkButton @click="remove()"><i class="fas fa-trash-alt"></i> {{ $ts._rooms.remove }}</MkButton> + </div> + </div> + + <div class="menu _section" v-if="isMyRoom"> + <div class="_content"> + <MkButton @click="add()"><i class="fas fa-box-open"></i> {{ $ts._rooms.addFurniture }}</MkButton> + </div> + <div class="_content"> + <MkSelect :model-value="roomType" @update:modelValue="updateRoomType($event)"> + <template #label>{{ $ts._rooms.roomType }}</template> + <option value="default">{{ $ts._rooms._roomType.default }}</option> + <option value="washitsu">{{ $ts._rooms._roomType.washitsu }}</option> + </MkSelect> + <label v-if="roomType === 'default'"> + <span>{{ $ts._rooms.carpetColor }}</span> + <input type="color" :value="carpetColor" @change="updateCarpetColor($event)"/> + </label> + </div> + <div class="_content"> + <MkButton inline :disabled="!changed" primary @click="save()"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton inline @click="clear()"><i class="fas fa-broom"></i> {{ $ts._rooms.clear }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import { Room } from '@/scripts/room/room'; +import * as Acct from 'misskey-js/built/acct'; +import XPreview from './preview.vue'; +const storeItems = require('@/scripts/room/furnitures.json5'); +import { query as urlQuery } from '@/scripts/url'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/form/select.vue'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +let room: Room; + +export default defineComponent({ + components: { + XPreview, + MkButton, + MkSelect, + }, + + props: { + acct: { + type: String, + required: true + }, + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.user ? { + title: this.$ts.room, + avatar: this.user, + } : null), + user: null, + objectSelected: false, + selectedFurnitureName: null, + selectedFurnitureInfo: null, + selectedFurnitureProps: null, + roomType: null, + carpetColor: null, + isTranslateMode: false, + isRotateMode: false, + isMyRoom: false, + changed: false, + }; + }, + + async mounted() { + window.addEventListener('beforeunload', this.beforeunload); + + this.user = await os.api('users/show', { + ...Acct.parse(this.acct) + }); + + this.isMyRoom = this.$i && (this.$i.id === this.user.id); + + const roomInfo = await os.api('room/show', { + userId: this.user.id + }); + + this.roomType = roomInfo.roomType; + this.carpetColor = roomInfo.carpetColor; + + room = new Room(this.user, this.isMyRoom, roomInfo, this.$el, { + graphicsQuality: ColdDeviceStorage.get('roomGraphicsQuality'), + onChangeSelect: obj => { + this.objectSelected = obj != null; + if (obj) { + const f = room.findFurnitureById(obj.name); + this.selectedFurnitureName = this.$t('_rooms._furnitures.' + f.type); + this.selectedFurnitureInfo = storeItems.find(x => x.id === f.type); + this.selectedFurnitureProps = f.props + ? JSON.parse(JSON.stringify(f.props)) // Disable reactivity + : null; + this.$nextTick(() => { + this.$refs.preview.selected(obj); + }); + } + }, + useOrthographicCamera: ColdDeviceStorage.get('roomUseOrthographicCamera'), + }); + }, + + beforeRouteLeave(to, from, next) { + if (this.changed) { + os.dialog({ + type: 'warning', + text: this.$ts.leaveConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) { + next(false); + } else { + next(); + } + }); + } else { + next(); + } + }, + + beforeUnmount() { + room.destroy(); + window.removeEventListener('beforeunload', this.beforeunload); + }, + + methods: { + beforeunload(e: BeforeUnloadEvent) { + if (this.changed) { + e.preventDefault(); + e.returnValue = ''; + } + }, + + async add() { + const { canceled, result: id } = await os.dialog({ + type: null, + title: this.$ts._rooms.addFurniture, + select: { + items: storeItems.map(item => ({ + value: item.id, text: this.$t('_rooms._furnitures.' + item.id) + })) + }, + showCancelButton: true + }); + if (canceled) return; + room.addFurniture(id); + this.changed = true; + }, + + remove() { + this.isTranslateMode = false; + this.isRotateMode = false; + room.removeFurniture(); + this.changed = true; + }, + + save() { + os.api('room/update', { + room: room.getRoomInfo() + }).then(() => { + this.changed = false; + os.success(); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + + clear() { + os.dialog({ + type: 'warning', + text: this.$ts._rooms.clearConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + room.removeAllFurnitures(); + this.changed = true; + }); + }, + + chooseImage(key, e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + room.updateProp(key, `/proxy/?${urlQuery({ url: file.thumbnailUrl })}`); + this.$refs.preview.selected(room.getSelectedObject()); + this.changed = true; + }); + }, + + updateColor(key, ev) { + room.updateProp(key, ev.target.value); + this.$refs.preview.selected(room.getSelectedObject()); + this.changed = true; + }, + + updateCarpetColor(ev) { + room.updateCarpetColor(ev.target.value); + this.carpetColor = ev.target.value; + this.changed = true; + }, + + updateRoomType(type) { + room.changeRoomType(type); + this.roomType = type; + this.changed = true; + }, + + translate() { + if (this.isTranslateMode) { + this.exit(); + } else { + this.isRotateMode = false; + this.isTranslateMode = true; + room.enterTransformMode('translate'); + } + this.changed = true; + }, + + rotate() { + if (this.isRotateMode) { + this.exit(); + } else { + this.isTranslateMode = false; + this.isRotateMode = true; + room.enterTransformMode('rotate'); + } + this.changed = true; + }, + + exit() { + this.isTranslateMode = false; + this.isRotateMode = false; + room.exitTransformMode(); + this.changed = true; + } + } +}); +</script> + +<style lang="scss" scoped> +.hveuntkp { + position: relative; + min-height: 500px; + + > ::v-deep(canvas) { + display: block; + } +} +</style> diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue new file mode 100644 index 0000000000..c26658cbc4 --- /dev/null +++ b/packages/client/src/pages/scratchpad.vue @@ -0,0 +1,149 @@ +<template> +<div class="iltifgqe"> + <div class="editor _panel _gap"> + <PrismEditor class="_code code" v-model="code" :highlight="highlighter" :line-numbers="false"/> + <MkButton style="position: absolute; top: 8px; right: 8px;" @click="run()" primary><i class="fas fa-play"></i></MkButton> + </div> + + <MkContainer :foldable="true" class="_gap"> + <template #header>{{ $ts.output }}</template> + <div class="bepmlvbi"> + <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div> + </div> + </MkContainer> + + <div class="_gap"> + {{ $ts.scratchpadDescription }} + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import 'prismjs'; +import { highlight, languages } from 'prismjs/components/prism-core'; +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/themes/prism-okaidia.css'; +import { PrismEditor } from 'vue-prism-editor'; +import 'vue-prism-editor/dist/prismeditor.min.css'; +import { AiScript, parse, utils, values } from '@syuilo/aiscript'; +import MkContainer from '@/components/ui/container.vue'; +import MkButton from '@/components/ui/button.vue'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkContainer, + MkButton, + PrismEditor, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.scratchpad, + icon: 'fas fa-terminal', + }, + code: '', + logs: [], + } + }, + + watch: { + code() { + localStorage.setItem('scratchpad', this.code); + } + }, + + created() { + const saved = localStorage.getItem('scratchpad'); + if (saved) { + this.code = saved; + } + }, + + methods: { + async run() { + this.logs = []; + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'scratchpad', + token: this.$i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + this.logs.push({ + id: Math.random(), + text: value.type === 'str' ? value.value : utils.valToString(value), + print: true + }); + }, + log: (type, params) => { + switch (type) { + case 'end': this.logs.push({ + id: Math.random(), + text: utils.valToString(params.val, true), + print: false + }); break; + default: break; + } + } + }); + + let ast; + try { + ast = parse(this.code); + } catch (e) { + os.dialog({ + type: 'error', + text: 'Syntax error :(' + }); + return; + } + try { + await aiscript.exec(ast); + } catch (e) { + os.dialog({ + type: 'error', + text: e + }); + } + }, + + highlighter(code) { + return highlight(code, languages.js, 'javascript'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.iltifgqe { + padding: 16px; + + > .editor { + position: relative; + } +} + +.bepmlvbi { + padding: 16px; + + > .log { + &:not(.print) { + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue new file mode 100644 index 0000000000..c7da3fe1c1 --- /dev/null +++ b/packages/client/src/pages/search.vue @@ -0,0 +1,53 @@ +<template> +<div class="_section"> + <div class="_content"> + <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: computed(() => this.$t('searchWith', { q: this.$route.query.q })), + icon: 'fas fa-search', + }, + pagination: { + endpoint: 'notes/search', + limit: 10, + params: () => ({ + query: this.$route.query.q, + channelId: this.$route.query.channel, + }) + }, + }; + }, + + watch: { + $route() { + (this.$refs.notes as any).reload(); + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue new file mode 100644 index 0000000000..dce217559a --- /dev/null +++ b/packages/client/src/pages/settings/2fa.vue @@ -0,0 +1,247 @@ +<template> +<section class="_card"> + <div class="_title"><i class="fas fa-lock"></i> {{ $ts.twoStepAuthentication }}</div> + <div class="_content"> + <MkButton v-if="!data && !$i.twoFactorEnabled" @click="register">{{ $ts._2fa.registerDevice }}</MkButton> + <template v-if="$i.twoFactorEnabled"> + <p>{{ $ts._2fa.alreadyRegistered }}</p> + <MkButton @click="unregister">{{ $ts.unregister }}</MkButton> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ $ts.securityKey }}</h2> + <p>{{ $ts._2fa.securityKeyInfo }}</p> + <div class="key-list"> + <div class="key" v-for="key in $i.securityKeysList"> + <h3>{{ key.name }}</h3> + <div class="last-used">{{ $ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ $ts.unregister }}</MkButton> + </div> + </div> + + <MkSwitch v-model="usePasswordLessLogin" @update:modelValue="updatePasswordLessLogin" v-if="$i.securityKeysList.length > 0">{{ $ts.passwordLessLogin }}</MkSwitch> + + <MkInfo warn v-if="registration && registration.error">{{ $ts.error }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $ts._2fa.registerKey }}</MkButton> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ $ts.tapSecurityKey }} + <i v-if="registration.saving && registration.stage == 0" class="fas fa-spinner fa-pulse fa-fw"></i> + </li> + <li v-if="registration.stage >= 1"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model="keyName" :max="30"> + <template #label>{{ $ts.securityKeyName }}</template> + </MkInput> + <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $ts.registerSecurityKey }}</MkButton> + <i v-if="registration.saving && registration.stage == 1" class="fas fa-spinner fa-pulse fa-fw"></i> + </MkForm> + </li> + </ol> + </template> + </template> + <div v-if="data && !$i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <I18n :src="$ts._2fa.step1" tag="span"> + <template #a> + <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> + </template> + <template #b> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> + </template> + </I18n> + </li> + <li>{{ $ts._2fa.step2 }}<br><img :src="data.qr"></li> + <li>{{ $ts._2fa.step3 }}<br> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ $ts.token }}</template></MkInput> + <MkButton primary @click="submit">{{ $ts.done }}</MkButton> + </li> + </ol> + <MkInfo>{{ $ts._2fa.step4 }}</MkInfo> + </div> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { hostname } from '@/config'; +import { byteify, hexify, stringify } from '@/scripts/2fa'; +import MkButton from '@/components/ui/button.vue'; +import MkInfo from '@/components/ui/info.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + MkButton, MkInfo, MkInput, MkSwitch + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.twoStepAuthentication, + icon: 'fas fa-lock' + }, + data: null, + supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$i.usePasswordLessLogin, + registration: null, + keyName: '', + token: null, + }; + }, + + methods: { + register() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register', { + password: password + }).then(data => { + this.data = data; + }); + }); + }, + + unregister() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/unregister', { + password: password + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + os.success(); + this.$i.twoFactorEnabled = false; + }); + }); + }, + + submit() { + os.api('i/2fa/done', { + token: this.token + }).then(() => { + os.success(); + this.$i.twoFactorEnabled = true; + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + }, + + registerKey() { + this.registration.saving = true; + os.api('i/2fa/key-done', { + password: this.registration.password, + name: this.keyName, + challengeId: this.registration.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringify(this.registration.credential.response.clientDataJSON), + attestationObject: hexify(this.registration.credential.response.attestationObject) + }).then(key => { + this.registration = null; + key.lastUsed = new Date(); + os.success(); + }) + }, + + unregisterKey(key) { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + return os.api('i/2fa/remove-key', { + password, + credentialId: key.id + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + os.success(); + }); + }); + }, + + addSecurityKey() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register-key', { + password + }).then(registration => { + this.registration = { + password, + challengeId: registration.challengeId, + stage: 0, + publicKeyOptions: { + challenge: byteify(registration.challenge, 'base64'), + rp: { + id: hostname, + name: 'Misskey' + }, + user: { + id: byteify(this.$i.id, 'ascii'), + name: this.$i.username, + displayName: this.$i.name, + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + timeout: 60000, + attestation: 'direct' + }, + saving: true + }; + return navigator.credentials.create({ + publicKey: this.registration.publicKeyOptions + }); + }).then(credential => { + this.registration.credential = credential; + this.registration.saving = false; + this.registration.stage = 1; + }).catch(err => { + console.warn('Error while registering?', err); + this.registration.error = err.message; + this.registration.stage = -1; + }); + }); + }, + + updatePasswordLessLogin() { + os.api('i/2fa/password-less', { + value: !!this.usePasswordLessLogin + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue new file mode 100644 index 0000000000..f3d5e2f2c3 --- /dev/null +++ b/packages/client/src/pages/settings/account-info.vue @@ -0,0 +1,185 @@ +<template> +<FormBase> + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </FormKeyValueView> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="stats"> + <template #label>{{ $ts.statistics }}</template> + <FormKeyValueView> + <template #key>{{ $ts.notesCount }}</template> + <template #value>{{ number(stats.notesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.repliesCount }}</template> + <template #value>{{ number(stats.repliesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.renotesCount }}</template> + <template #value>{{ number(stats.renotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.repliedCount }}</template> + <template #value>{{ number(stats.repliedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.renotedCount }}</template> + <template #value>{{ number(stats.renotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pollVotesCount }}</template> + <template #value>{{ number(stats.pollVotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pollVotedCount }}</template> + <template #value>{{ number(stats.pollVotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.sentReactionsCount }}</template> + <template #value>{{ number(stats.sentReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.receivedReactionsCount }}</template> + <template #value>{{ number(stats.receivedReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.noteFavoritesCount }}</template> + <template #value>{{ number(stats.noteFavoritesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }}</template> + <template #value>{{ number(stats.followingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }} ({{ $ts.local }})</template> + <template #value>{{ number(stats.localFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }} ({{ $ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }}</template> + <template #value>{{ number(stats.followersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }} ({{ $ts.local }})</template> + <template #value>{{ number(stats.localFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }} ({{ $ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pageLikesCount }}</template> + <template #value>{{ number(stats.pageLikesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pageLikedCount }}</template> + <template #value>{{ number(stats.pageLikedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.driveFilesCount }}</template> + <template #value>{{ number(stats.driveFilesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.driveUsage }}</template> + <template #value>{{ bytes(stats.driveUsage) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.reversiCount }}</template> + <template #value>{{ number(stats.reversiCount) }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <template #label>{{ $ts.other }}</template> + <FormKeyValueView> + <template #key>emailVerified</template> + <template #value>{{ $i.emailVerified ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>twoFactorEnabled</template> + <template #value>{{ $i.twoFactorEnabled ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>securityKeys</template> + <template #value>{{ $i.securityKeys ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>usePasswordLessLogin</template> + <template #value>{{ $i.usePasswordLessLogin ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isModerator</template> + <template #value>{{ $i.isModerator ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isAdmin</template> + <template #value>{{ $i.isAdmin ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.accountInfo, + icon: 'fas fa-info-circle' + }, + stats: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('users/stats', { + userId: this.$i.id + }).then(stats => { + this.stats = stats; + }); + }, + + methods: { + number, + bytes, + } +}); +</script> diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue new file mode 100644 index 0000000000..94a3c9483d --- /dev/null +++ b/packages/client/src/pages/settings/accounts.vue @@ -0,0 +1,149 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormButton @click="addAccount" primary><i class="fas fa-plus"></i> {{ $ts.addAccount }}</FormButton> + + <div class="_debobigegoItem _button" v-for="account in accounts" :key="account.id" @click="menu(account, $event)"> + <div class="_debobigegoPanel lcjjdxlm"> + <div class="avatar"> + <MkAvatar :user="account" class="avatar"/> + </div> + <div class="body"> + <div class="name"> + <MkUserName :user="account"/> + </div> + <div class="acct"> + <MkAcct :user="account"/> + </div> + </div> + </div> + </div> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { getAccounts, addAccount, login } from '@/account'; + +export default defineComponent({ + components: { + FormBase, + FormSuspense, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.accounts, + icon: 'fas fa-users', + bg: 'var(--bg)', + }, + storedAccounts: getAccounts().then(accounts => accounts.filter(x => x.id !== this.$i.id)), + accounts: null, + init: async () => os.api('users/show', { + userIds: (await this.storedAccounts).map(x => x.id) + }).then(accounts => { + this.accounts = accounts; + }), + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + menu(account, ev) { + os.popupMenu([{ + text: this.$ts.switch, + icon: 'fas fa-exchange-alt', + action: () => this.switchAccount(account), + }, { + text: this.$ts.remove, + icon: 'fas fa-trash-alt', + danger: true, + action: () => this.removeAccount(account), + }], ev.currentTarget || ev.target); + }, + + addAccount(ev) { + os.popupMenu([{ + text: this.$ts.existingAccount, + action: () => { this.addExistingAccount(); }, + }, { + text: this.$ts.createAccount, + action: () => { this.createAccount(); }, + }], ev.currentTarget || ev.target); + }, + + addExistingAccount() { + os.popup(import('@/components/signin-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + os.success(); + }, + }, 'closed'); + }, + + createAccount() { + os.popup(import('@/components/signup-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + this.switchAccountWithToken(res.i); + }, + }, 'closed'); + }, + + async switchAccount(account: any) { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + this.switchAccountWithToken(token); + }, + + switchAccountWithToken(token: string) { + login(token); + }, + } +}); +</script> + +<style lang="scss" scoped> +.lcjjdxlm { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + + > .avatar { + width: 50px; + height: 50px; + } + } + + > .body { + display: flex; + flex-direction: column; + justify-content: center; + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue new file mode 100644 index 0000000000..1def0189ec --- /dev/null +++ b/packages/client/src/pages/settings/api.vue @@ -0,0 +1,65 @@ +<template> +<FormBase> + <FormButton @click="generateToken" primary>{{ $ts.generateAccessToken }}</FormButton> + <FormLink to="/settings/apps">{{ $ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'API', + icon: 'fas fa-key', + bg: 'var(--bg)', + }, + isDesktop: window.innerWidth >= 1100, + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + generateToken() { + os.popup(import('@/components/token-generate-window.vue'), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.dialog({ + type: 'success', + title: this.$ts.token, + text: token + }); + }, + }, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue new file mode 100644 index 0000000000..6eec80d805 --- /dev/null +++ b/packages/client/src/pages/settings/apps.vue @@ -0,0 +1,113 @@ +<template> +<FormBase> + <FormPagination :pagination="pagination" ref="list"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.nothing }}</div> + </div> + </template> + <template #default="{items}"> + <div class="_debobigegoPanel bfomjevm" v-for="token in items" :key="token.id"> + <img class="icon" :src="token.iconUrl" alt="" v-if="token.iconUrl"/> + <div class="body"> + <div class="name">{{ token.name }}</div> + <div class="description">{{ token.description }}</div> + <div class="_keyValue"> + <div>{{ $ts.installedDate }}:</div> + <div><MkTime :time="token.createdAt"/></div> + </div> + <div class="_keyValue"> + <div>{{ $ts.lastUsedDate }}:</div> + <div><MkTime :time="token.lastUsedAt"/></div> + </div> + <div class="actions"> + <button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button> + </div> + <details> + <summary>{{ $ts.details }}</summary> + <ul> + <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </details> + </div> + </div> + </template> + </FormPagination> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormPagination from '@/components/debobigego/pagination.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.installedApps, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/apps', + limit: 100, + params: { + sort: '+lastUsedAt' + } + }, + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + revoke(token) { + os.api('i/revoke-token', { tokenId: token.id }).then(() => { + this.$refs.list.reload(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.bfomjevm { + display: flex; + padding: 16px; + + > .icon { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 50px; + height: 50px; + border-radius: 8px; + } + + > .body { + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue new file mode 100644 index 0000000000..8c878fb084 --- /dev/null +++ b/packages/client/src/pages/settings/custom-css.vue @@ -0,0 +1,73 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts.customCssWarn }}</FormInfo> + + <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace" style="tab-size: 2;"> + <span>{{ $ts.local }}</span> + </FormTextarea> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.customCss, + icon: 'fas fa-code', + bg: 'var(--bg)', + }, + localCustomCss: localStorage.getItem('customCss') + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + this.$watch('localCustomCss', this.apply); + }, + + methods: { + async apply() { + localStorage.setItem('customCss', this.localCustomCss); + + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue new file mode 100644 index 0000000000..a96c6cd685 --- /dev/null +++ b/packages/client/src/pages/settings/deck.vue @@ -0,0 +1,107 @@ +<template> +<FormBase> + <FormGroup> + <template #label>{{ $ts.defaultNavigationBehaviour }}</template> + <FormSwitch v-model="navWindow">{{ $ts.openInWindow }}</FormSwitch> + </FormGroup> + + <FormSwitch v-model="alwaysShowMainColumn">{{ $ts._deck.alwaysShowMainColumn }}</FormSwitch> + + <FormRadios v-model="columnAlign"> + <template #desc>{{ $ts._deck.columnAlign }}</template> + <option value="left">{{ $ts.left }}</option> + <option value="center">{{ $ts.center }}</option> + </FormRadios> + + <FormRadios v-model="columnHeaderHeight"> + <template #desc>{{ $ts._deck.columnHeaderHeight }}</template> + <option :value="42">{{ $ts.narrow }}</option> + <option :value="45">{{ $ts.medium }}</option> + <option :value="48">{{ $ts.wide }}</option> + </FormRadios> + + <FormInput v-model="columnMargin" type="number"> + <span>{{ $ts._deck.columnMargin }}</span> + <template #suffix>px</template> + </FormInput> + + <FormLink @click="setProfile">{{ $ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { deckStore } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSwitch, + FormLink, + FormInput, + FormRadios, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.deck, + icon: 'fas fa-columns', + bg: 'var(--bg)', + }, + } + }, + + computed: { + navWindow: deckStore.makeGetterSetter('navWindow'), + alwaysShowMainColumn: deckStore.makeGetterSetter('alwaysShowMainColumn'), + columnAlign: deckStore.makeGetterSetter('columnAlign'), + columnMargin: deckStore.makeGetterSetter('columnMargin'), + columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'), + profile: deckStore.makeGetterSetter('profile'), + }, + + watch: { + async navWindow() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async setProfile() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts._deck.profile, + input: { + allowEmpty: false + } + }); + if (canceled) return; + this.profile = name; + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue new file mode 100644 index 0000000000..018f7c795e --- /dev/null +++ b/packages/client/src/pages/settings/delete-account.vue @@ -0,0 +1,68 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo>{{ $ts._accountDelete.sendEmail }}</FormInfo> + <FormButton @click="deleteAccount" danger v-if="!$i.isDeleted">{{ $ts._accountDelete.requestAccountDelete }}</FormButton> + <FormButton disabled v-else>{{ $ts._accountDelete.inProgress }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { debug } from '@/config'; +import { signout } from '@/account'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormGroup, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._accountDelete.accountDelete, + icon: 'fas fa-exclamation-triangle', + bg: 'var(--bg)', + }, + debug, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async deleteAccount() { + const { canceled, result: password } = await os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }); + if (canceled) return; + + await os.apiWithDialog('i/delete-account', { + password: password + }); + + await os.dialog({ + title: this.$ts._accountDelete.started, + }); + + signout(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue new file mode 100644 index 0000000000..ed5282e23d --- /dev/null +++ b/packages/client/src/pages/settings/drive.vue @@ -0,0 +1,147 @@ +<template> +<FormBase class=""> + <FormGroup v-if="!fetching"> + <template #label>{{ $ts.usageAmount }}</template> + <div class="_debobigegoItem uawsfosz"> + <div class="_debobigegoPanel"> + <div class="meter"><div :style="meterStyle"></div></div> + </div> + </div> + <FormKeyValueView> + <template #key>{{ $ts.capacity }}</template> + <template #value>{{ bytes(capacity, 1) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.inUse }}</template> + <template #value>{{ bytes(usage, 1) }}</template> + </FormKeyValueView> + </FormGroup> + + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.statistics }}</div> + <div class="_debobigegoPanel"> + <div ref="chart"></div> + </div> + </div> + + <FormButton :center="false" @click="chooseUploadFolder()" primary> + {{ $ts.uploadFolder }} + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="fas fa-folder-open"></i></template> + </FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import FormButton from '@/components/debobigego/button.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; + +// TODO: render chart + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.drive, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + }, + fetching: true, + usage: null, + capacity: null, + uploadFolder: null, + } + }, + + computed: { + meterStyle(): any { + return { + width: `${this.usage / this.capacity * 100}%`, + background: tinycolor({ + h: 180 - (this.usage / this.capacity * 180), + s: 0.7, + l: 0.5 + }) + }; + } + }, + + async created() { + os.api('drive').then(info => { + this.capacity = info.capacity; + this.usage = info.usage; + this.fetching = false; + this.$nextTick(() => { + this.renderChart(); + }); + }); + + if (this.$store.state.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.uploadFolder + }); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + chooseUploadFolder() { + os.selectDriveFolder(false).then(async folder => { + this.$store.set('uploadFolder', folder ? folder.id : null); + os.success(); + if (this.$store.state.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.uploadFolder + }); + } else { + this.uploadFolder = null; + } + }); + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.uawsfosz { + > div { + padding: 24px; + + > .meter { + $size: 12px; + background: rgba(0, 0, 0, 0.1); + border-radius: math.div($size, 2); + overflow: hidden; + + > div { + height: $size; + border-radius: math.div($size, 2); + } + } + } +} +</style> diff --git a/packages/client/src/pages/settings/email-address.vue b/packages/client/src/pages/settings/email-address.vue new file mode 100644 index 0000000000..476d0c0e17 --- /dev/null +++ b/packages/client/src/pages/settings/email-address.vue @@ -0,0 +1,70 @@ +<template> +<FormBase> + <FormGroup> + <FormInput v-model="emailAddress" type="email"> + {{ $ts.emailAddress }} + <template #desc v-if="$i.email && !$i.emailVerified">{{ $ts.verificationEmailSent }}</template> + <template #desc v-else-if="emailAddress === $i.email && $i.emailVerified">{{ $ts.emailVerified }}</template> + </FormInput> + </FormGroup> + <FormButton @click="save" primary>{{ $ts.save }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/form/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormInput, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailAddress, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + emailAddress: null, + code: null, + } + }, + + created() { + this.emailAddress = this.$i.email; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.apiWithDialog('i/update-email', { + password: password, + email: this.emailAddress, + }); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/email-notification.vue b/packages/client/src/pages/settings/email-notification.vue new file mode 100644 index 0000000000..c1735a0728 --- /dev/null +++ b/packages/client/src/pages/settings/email-notification.vue @@ -0,0 +1,91 @@ +<template> +<FormBase> + <FormGroup> + <FormSwitch v-model="mention"> + {{ $ts._notification._types.mention }} + </FormSwitch> + <FormSwitch v-model="reply"> + {{ $ts._notification._types.reply }} + </FormSwitch> + <FormSwitch v-model="quote"> + {{ $ts._notification._types.quote }} + </FormSwitch> + <FormSwitch v-model="follow"> + {{ $ts._notification._types.follow }} + </FormSwitch> + <FormSwitch v-model="receiveFollowRequest"> + {{ $ts._notification._types.receiveFollowRequest }} + </FormSwitch> + <FormSwitch v-model="groupInvited"> + {{ $ts._notification._types.groupInvited }} + </FormSwitch> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSwitch, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailNotification, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + + mention: this.$i.emailNotificationTypes.includes('mention'), + reply: this.$i.emailNotificationTypes.includes('reply'), + quote: this.$i.emailNotificationTypes.includes('quote'), + follow: this.$i.emailNotificationTypes.includes('follow'), + receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'), + groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'), + } + }, + + created() { + this.$watch('mention', this.save); + this.$watch('reply', this.save); + this.$watch('quote', this.save); + this.$watch('follow', this.save); + this.$watch('receiveFollowRequest', this.save); + this.$watch('groupInvited', this.save); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.api('i/update', { + emailNotificationTypes: [ + ...[this.mention ? 'mention' : null], + ...[this.reply ? 'reply' : null], + ...[this.quote ? 'quote' : null], + ...[this.follow ? 'follow' : null], + ...[this.receiveFollowRequest ? 'receiveFollowRequest' : null], + ...[this.groupInvited ? 'groupInvited' : null], + ].filter(x => x != null) + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue new file mode 100644 index 0000000000..d1dda20f00 --- /dev/null +++ b/packages/client/src/pages/settings/email.vue @@ -0,0 +1,66 @@ +<template> +<FormBase> + <FormGroup> + <template #label>{{ $ts.emailAddress }}</template> + <FormLink to="/settings/email/address"> + <template v-if="$i.email && !$i.emailVerified" #icon><i class="fas fa-exclamation-triangle" style="color: var(--warn);"></i></template> + <template v-else-if="$i.email && $i.emailVerified" #icon><i class="fas fa-check" style="color: var(--success);"></i></template> + {{ $i.email || $ts.notSet }} + </FormLink> + </FormGroup> + + <FormLink to="/settings/email/notification"> + <template #icon><i class="fas fa-bell"></i></template> + {{ $ts.emailNotification }} + </FormLink> + + <FormSwitch :value="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> + {{ $ts.receiveAnnouncementFromInstance }} + </FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormSwitch, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.email, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + onChangeReceiveAnnouncementEmail(v) { + os.api('i/update', { + receiveAnnouncementEmail: v + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/experimental-features.vue b/packages/client/src/pages/settings/experimental-features.vue new file mode 100644 index 0000000000..5a7bcb3b41 --- /dev/null +++ b/packages/client/src/pages/settings/experimental-features.vue @@ -0,0 +1,52 @@ +<template> +<FormBase> + <FormButton @click="error()">error test</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.experimentalFeatures, + icon: 'fas fa-flask' + }, + stats: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + error() { + throw new Error('Test error'); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue new file mode 100644 index 0000000000..8e3dcc3e41 --- /dev/null +++ b/packages/client/src/pages/settings/general.vue @@ -0,0 +1,223 @@ +<template> +<FormBase> + <FormSwitch v-model="showFixedPostForm">{{ $ts.showFixedPostForm }}</FormSwitch> + + <FormSelect v-model="lang"> + <template #label>{{ $ts.uiLanguage }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="$ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.behavior }}</template> + <FormSwitch v-model="imageNewTab">{{ $ts.openImageInNewTab }}</FormSwitch> + <FormSwitch v-model="enableInfiniteScroll">{{ $ts.enableInfiniteScroll }}</FormSwitch> + <FormSwitch v-model="useReactionPickerForContextMenu">{{ $ts.useReactionPickerForContextMenu }}</FormSwitch> + <FormSwitch v-model="disablePagesScript">{{ $ts.disablePagesScript }}</FormSwitch> + </FormGroup> + + <FormSelect v-model="serverDisconnectedBehavior"> + <template #label>{{ $ts.whenServerDisconnected }}</template> + <option value="reload">{{ $ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ $ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ $ts._serverDisconnectedBehavior.quiet }}</option> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.appearance }}</template> + <FormSwitch v-model="disableAnimatedMfm">{{ $ts.disableAnimatedMfm }}</FormSwitch> + <FormSwitch v-model="reduceAnimation">{{ $ts.reduceUiAnimation }}</FormSwitch> + <FormSwitch v-model="useBlurEffect">{{ $ts.useBlurEffect }}</FormSwitch> + <FormSwitch v-model="useBlurEffectForModal">{{ $ts.useBlurEffectForModal }}</FormSwitch> + <FormSwitch v-model="showGapBetweenNotesInTimeline">{{ $ts.showGapBetweenNotesInTimeline }}</FormSwitch> + <FormSwitch v-model="loadRawImages">{{ $ts.loadRawImages }}</FormSwitch> + <FormSwitch v-model="disableShowingAnimatedImages">{{ $ts.disableShowingAnimatedImages }}</FormSwitch> + <FormSwitch v-model="squareAvatars">{{ $ts.squareAvatars }}</FormSwitch> + <FormSwitch v-model="useSystemFont">{{ $ts.useSystemFont }}</FormSwitch> + <FormSwitch v-model="useOsNativeEmojis">{{ $ts.useOsNativeEmojis }} + <div><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪" :key="useOsNativeEmojis"/></div> + </FormSwitch> + </FormGroup> + + <FormGroup> + <FormSwitch v-model="aiChanMode">{{ $ts.aiChanMode }}</FormSwitch> + </FormGroup> + + <FormRadios v-model="fontSize"> + <template #desc>{{ $ts.fontSize }}</template> + <option value="small"><span style="font-size: 14px;">Aa</span></option> + <option :value="null"><span style="font-size: 16px;">Aa</span></option> + <option value="large"><span style="font-size: 18px;">Aa</span></option> + <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option> + </FormRadios> + + <FormSelect v-model="instanceTicker"> + <template #label>{{ $ts.instanceTicker }}</template> + <option value="none">{{ $ts._instanceTicker.none }}</option> + <option value="remote">{{ $ts._instanceTicker.remote }}</option> + <option value="always">{{ $ts._instanceTicker.always }}</option> + </FormSelect> + + <FormSelect v-model="nsfw"> + <template #label>{{ $ts.nsfw }}</template> + <option value="respect">{{ $ts._nsfw.respect }}</option> + <option value="ignore">{{ $ts._nsfw.ignore }}</option> + <option value="force">{{ $ts._nsfw.force }}</option> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.defaultNavigationBehaviour }}</template> + <FormSwitch v-model="defaultSideView">{{ $ts.openInSideView }}</FormSwitch> + </FormGroup> + + <FormSelect v-model="chatOpenBehavior"> + <template #label>{{ $ts.chatOpenBehavior }}</template> + <option value="page">{{ $ts.showInPage }}</option> + <option value="window">{{ $ts.openInWindow }}</option> + <option value="popout">{{ $ts.popout }}</option> + </FormSelect> + + <FormLink to="/settings/deck">{{ $ts.deck }}</FormLink> + + <FormLink to="/settings/custom-css"><template #icon><i class="fas fa-code"></i></template>{{ $ts.customCss }}</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import MkLink from '@/components/link.vue'; +import { langs } from '@/config'; +import { defaultStore } from '@/store'; +import { ColdDeviceStorage } from '@/store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkLink, + FormSwitch, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.general, + icon: 'fas fa-cogs', + bg: 'var(--bg)' + }, + langs, + lang: localStorage.getItem('lang'), + fontSize: localStorage.getItem('fontSize'), + useSystemFont: localStorage.getItem('useSystemFont') != null, + } + }, + + computed: { + serverDisconnectedBehavior: defaultStore.makeGetterSetter('serverDisconnectedBehavior'), + reduceAnimation: defaultStore.makeGetterSetter('animation', v => !v, v => !v), + useBlurEffectForModal: defaultStore.makeGetterSetter('useBlurEffectForModal'), + useBlurEffect: defaultStore.makeGetterSetter('useBlurEffect'), + showGapBetweenNotesInTimeline: defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'), + disableAnimatedMfm: defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v), + useOsNativeEmojis: defaultStore.makeGetterSetter('useOsNativeEmojis'), + disableShowingAnimatedImages: defaultStore.makeGetterSetter('disableShowingAnimatedImages'), + loadRawImages: defaultStore.makeGetterSetter('loadRawImages'), + imageNewTab: defaultStore.makeGetterSetter('imageNewTab'), + nsfw: defaultStore.makeGetterSetter('nsfw'), + disablePagesScript: defaultStore.makeGetterSetter('disablePagesScript'), + showFixedPostForm: defaultStore.makeGetterSetter('showFixedPostForm'), + defaultSideView: defaultStore.makeGetterSetter('defaultSideView'), + chatOpenBehavior: ColdDeviceStorage.makeGetterSetter('chatOpenBehavior'), + instanceTicker: defaultStore.makeGetterSetter('instanceTicker'), + enableInfiniteScroll: defaultStore.makeGetterSetter('enableInfiniteScroll'), + useReactionPickerForContextMenu: defaultStore.makeGetterSetter('useReactionPickerForContextMenu'), + squareAvatars: defaultStore.makeGetterSetter('squareAvatars'), + aiChanMode: defaultStore.makeGetterSetter('aiChanMode'), + }, + + watch: { + lang() { + localStorage.setItem('lang', this.lang); + localStorage.removeItem('locale'); + this.reloadAsk(); + }, + + fontSize() { + if (this.fontSize == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', this.fontSize); + } + this.reloadAsk(); + }, + + useSystemFont() { + if (this.useSystemFont) { + localStorage.setItem('useSystemFont', 't'); + } else { + localStorage.removeItem('useSystemFont'); + } + this.reloadAsk(); + }, + + enableInfiniteScroll() { + this.reloadAsk(); + }, + + squareAvatars() { + this.reloadAsk(); + }, + + aiChanMode() { + this.reloadAsk(); + }, + + showGapBetweenNotesInTimeline() { + this.reloadAsk(); + }, + + instanceTicker() { + this.reloadAsk(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async reloadAsk() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue new file mode 100644 index 0000000000..8923483b98 --- /dev/null +++ b/packages/client/src/pages/settings/import-export.vue @@ -0,0 +1,112 @@ +<template> +<div style="margin: 16px;"> + <FormSection> + <template #label>{{ $ts._exportOrImport.allNotes }}</template> + <MkButton :class="$style.button" inline @click="doExport('notes')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.followingList }}</template> + <MkButton :class="$style.button" inline @click="doExport('following')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('following', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.userLists }}</template> + <MkButton :class="$style.button" inline @click="doExport('user-lists')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('user-lists', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.muteList }}</template> + <MkButton :class="$style.button" inline @click="doExport('muting')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('muting', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.blockingList }}</template> + <MkButton :class="$style.button" inline @click="doExport('blocking')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('blocking', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSection, + MkButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.importAndExport, + icon: 'fas fa-boxes', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + doExport(target) { + os.api( + target === 'notes' ? 'i/export-notes' : + target === 'following' ? 'i/export-following' : + target === 'blocking' ? 'i/export-blocking' : + target === 'user-lists' ? 'i/export-user-lists' : + target === 'muting' ? 'i/export-mute' : + null, {}) + .then(() => { + os.dialog({ + type: 'info', + text: this.$ts.exportRequested + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + + async doImport(target, e) { + const file = await selectFile(e.currentTarget || e.target); + + os.api( + target === 'following' ? 'i/import-following' : + target === 'user-lists' ? 'i/import-user-lists' : + target === 'muting' ? 'i/import-muting' : + target === 'blocking' ? 'i/import-blocking' : + null, { + fileId: file.id + }).then(() => { + os.dialog({ + type: 'info', + text: this.$ts.importRequested + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + } +}); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue new file mode 100644 index 0000000000..b9d3903269 --- /dev/null +++ b/packages/client/src/pages/settings/index.vue @@ -0,0 +1,326 @@ +<template> +<div class="vvcocwet" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || page == null"> + <MkSpacer :content-max="700"> + <div class="baaadecd"> + <div class="title">{{ $ts.settings }}</div> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div class="main"> + <component :is="component" :key="page" v-bind="pageProps"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkInfo from '@/components/ui/info.vue'; +import MkSuperMenu from '@/components/ui/super-menu.vue'; +import { scroll } from '@/scripts/scroll'; +import { signout } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; +import { instance } from '@/instance'; +import { $i } from '@/account'; + +export default defineComponent({ + components: { + MkInfo, + MkSuperMenu, + }, + + props: { + initialPage: { + type: String, + required: false + } + }, + + setup(props, context) { + const indexInfo = { + title: i18n.locale.settings, + icon: 'fas fa-cog', + bg: 'var(--bg)', + hideHeader: true, + }; + const INFO = ref(indexInfo); + const page = ref(props.initialPage); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const menuDef = computed(() => [{ + title: i18n.locale.basicSettings, + items: [{ + icon: 'fas fa-user', + text: i18n.locale.profile, + to: '/settings/profile', + active: page.value === 'profile', + }, { + icon: 'fas fa-lock-open', + text: i18n.locale.privacy, + to: '/settings/privacy', + active: page.value === 'privacy', + }, { + icon: 'fas fa-laugh', + text: i18n.locale.reaction, + to: '/settings/reaction', + active: page.value === 'reaction', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.drive, + to: '/settings/drive', + active: page.value === 'drive', + }, { + icon: 'fas fa-bell', + text: i18n.locale.notifications, + to: '/settings/notifications', + active: page.value === 'notifications', + }, { + icon: 'fas fa-envelope', + text: i18n.locale.email, + to: '/settings/email', + active: page.value === 'email', + }, { + icon: 'fas fa-share-alt', + text: i18n.locale.integration, + to: '/settings/integration', + active: page.value === 'integration', + }, { + icon: 'fas fa-lock', + text: i18n.locale.security, + to: '/settings/security', + active: page.value === 'security', + }], + }, { + title: i18n.locale.clientSettings, + items: [{ + icon: 'fas fa-cogs', + text: i18n.locale.general, + to: '/settings/general', + active: page.value === 'general', + }, { + icon: 'fas fa-palette', + text: i18n.locale.theme, + to: '/settings/theme', + active: page.value === 'theme', + }, { + icon: 'fas fa-list-ul', + text: i18n.locale.menu, + to: '/settings/menu', + active: page.value === 'menu', + }, { + icon: 'fas fa-music', + text: i18n.locale.sounds, + to: '/settings/sounds', + active: page.value === 'sounds', + }, { + icon: 'fas fa-plug', + text: i18n.locale.plugins, + to: '/settings/plugin', + active: page.value === 'plugin', + }], + }, { + title: i18n.locale.otherSettings, + items: [{ + icon: 'fas fa-boxes', + text: i18n.locale.importAndExport, + to: '/settings/import-export', + active: page.value === 'import-export', + }, { + icon: 'fas fa-ban', + text: i18n.locale.muteAndBlock, + to: '/settings/mute-block', + active: page.value === 'mute-block', + }, { + icon: 'fas fa-comment-slash', + text: i18n.locale.wordMute, + to: '/settings/word-mute', + active: page.value === 'word-mute', + }, { + icon: 'fas fa-key', + text: 'API', + to: '/settings/api', + active: page.value === 'api', + }, { + icon: 'fas fa-ellipsis-h', + text: i18n.locale.other, + to: '/settings/other', + active: page.value === 'other', + }], + }, { + items: [{ + type: 'button', + icon: 'fas fa-trash', + text: i18n.locale.clearCache, + action: () => { + localStorage.removeItem('locale'); + localStorage.removeItem('theme'); + unisonReload(); + }, + }, { + type: 'button', + icon: 'fas fa-sign-in-alt fa-flip-horizontal', + text: i18n.locale.logout, + action: () => { + signout(); + }, + danger: true, + },], + }]); + + const pageProps = ref({}); + const component = computed(() => { + if (page.value == null) return null; + switch (page.value) { + case 'accounts': return defineAsyncComponent(() => import('./accounts.vue')); + case 'profile': return defineAsyncComponent(() => import('./profile.vue')); + case 'privacy': return defineAsyncComponent(() => import('./privacy.vue')); + case 'reaction': return defineAsyncComponent(() => import('./reaction.vue')); + case 'drive': return defineAsyncComponent(() => import('./drive.vue')); + case 'notifications': return defineAsyncComponent(() => import('./notifications.vue')); + case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue')); + case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue')); + case 'integration': return defineAsyncComponent(() => import('./integration.vue')); + case 'security': return defineAsyncComponent(() => import('./security.vue')); + case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); + case 'api': return defineAsyncComponent(() => import('./api.vue')); + case 'apps': return defineAsyncComponent(() => import('./apps.vue')); + case 'other': return defineAsyncComponent(() => import('./other.vue')); + case 'general': return defineAsyncComponent(() => import('./general.vue')); + case 'email': return defineAsyncComponent(() => import('./email.vue')); + case 'email/address': return defineAsyncComponent(() => import('./email-address.vue')); + case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue')); + case 'theme': return defineAsyncComponent(() => import('./theme.vue')); + case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); + case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); + case 'menu': return defineAsyncComponent(() => import('./menu.vue')); + case 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); + case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue')); + case 'deck': return defineAsyncComponent(() => import('./deck.vue')); + case 'plugin': return defineAsyncComponent(() => import('./plugin.vue')); + case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue')); + case 'plugin/manage': return defineAsyncComponent(() => import('./plugin.manage.vue')); + case 'import-export': return defineAsyncComponent(() => import('./import-export.vue')); + case 'account-info': return defineAsyncComponent(() => import('./account-info.vue')); + case 'update': return defineAsyncComponent(() => import('./update.vue')); + case 'registry': return defineAsyncComponent(() => import('./registry.vue')); + case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue')); + case 'experimental-features': return defineAsyncComponent(() => import('./experimental-features.vue')); + } + if (page.value.startsWith('registry/keys/system/')) { + return defineAsyncComponent(() => import('./registry.keys.vue')); + } + if (page.value.startsWith('registry/value/system/')) { + return defineAsyncComponent(() => import('./registry.value.vue')); + } + }); + + watch(component, () => { + pageProps.value = {}; + + if (page.value) { + if (page.value.startsWith('registry/keys/system/')) { + pageProps.value.scope = page.value.replace('registry/keys/system/', '').split('/'); + } + if (page.value.startsWith('registry/value/system/')) { + const path = page.value.replace('registry/value/system/', '').split('/'); + pageProps.value.xKey = path.pop(); + pageProps.value.scope = path; + } + } + + nextTick(() => { + scroll(el.value, { top: 0 }); + }); + }, { immediate: true }); + + watch(() => props.initialPage, () => { + if (props.initialPage == null && !narrow.value) { + page.value = 'profile'; + } else { + page.value = props.initialPage; + if (props.initialPage == null) { + INFO.value = indexInfo; + } + } + }); + + onMounted(() => { + narrow.value = el.value.offsetWidth < 800; + if (!narrow.value) { + page.value = 'profile'; + } + }); + + const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); + + return { + [symbols.PAGE_INFO]: INFO, + page, + menuDef, + narrow, + view, + el, + pageProps, + component, + emailNotConfigured, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.vvcocwet { + > .nav { + .baaadecd { + > .title { + margin: 16px; + font-size: 1.5em; + font-weight: bold; + } + + > .info { + margin: 0 16px; + } + + > .accounts { + > .avatar { + display: block; + width: 50px; + height: 50px; + margin: 8px auto 16px auto; + } + } + } + } + + &.wide { + display: flex; + max-width: 1000px; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + box-sizing: border-box; + overflow: auto; + + .baaadecd { + > .title { + margin: 24px 0; + } + } + } + + > .main { + flex: 1; + min-width: 0; + overflow: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue new file mode 100644 index 0000000000..405f93b779 --- /dev/null +++ b/packages/client/src/pages/settings/integration.vue @@ -0,0 +1,141 @@ +<template> +<FormBase> + <div class="_debobigegoItem" v-if="enableTwitterIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-twitter"></i> Twitter</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.twitter">{{ $ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p> + <MkButton v-if="integrations.twitter" @click="disconnectTwitter" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectTwitter" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> + + <div class="_debobigegoItem" v-if="enableDiscordIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-discord"></i> Discord</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.discord">{{ $ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p> + <MkButton v-if="integrations.discord" @click="disconnectDiscord" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectDiscord" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> + + <div class="_debobigegoItem" v-if="enableGithubIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-github"></i> GitHub</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.github">{{ $ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p> + <MkButton v-if="integrations.github" @click="disconnectGithub" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectGithub" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { apiUrl } from '@/config'; +import FormBase from '@/components/debobigego/base.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + MkButton + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.integration, + icon: 'fas fa-share-alt', + bg: 'var(--bg)', + }, + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + enableTwitterIntegration: false, + enableDiscordIntegration: false, + enableGithubIntegration: false, + }; + }, + + computed: { + integrations() { + return this.$i.integrations; + }, + + meta() { + return this.$instance; + }, + }, + + created() { + this.enableTwitterIntegration = this.meta.enableTwitterIntegration; + this.enableDiscordIntegration = this.meta.enableDiscordIntegration; + this.enableGithubIntegration = this.meta.enableGithubIntegration; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + document.cookie = `igi=${this.$i.token}; path=/;` + + ` max-age=31536000;` + + (document.location.protocol.startsWith('https') ? ' secure' : ''); + + this.$watch('integrations', () => { + if (this.integrations.twitter) { + if (this.twitterForm) this.twitterForm.close(); + } + if (this.integrations.discord) { + if (this.discordForm) this.discordForm.close(); + } + if (this.integrations.github) { + if (this.githubForm) this.githubForm.close(); + } + }, { + deep: true + }); + }, + + methods: { + connectTwitter() { + this.twitterForm = window.open(apiUrl + '/connect/twitter', + 'twitter_connect_window', + 'height=570, width=520'); + }, + + disconnectTwitter() { + window.open(apiUrl + '/disconnect/twitter', + 'twitter_disconnect_window', + 'height=570, width=520'); + }, + + connectDiscord() { + this.discordForm = window.open(apiUrl + '/connect/discord', + 'discord_connect_window', + 'height=570, width=520'); + }, + + disconnectDiscord() { + window.open(apiUrl + '/disconnect/discord', + 'discord_disconnect_window', + 'height=570, width=520'); + }, + + connectGithub() { + this.githubForm = window.open(apiUrl + '/connect/github', + 'github_connect_window', + 'height=570, width=520'); + }, + + disconnectGithub() { + window.open(apiUrl + '/disconnect/github', + 'github_disconnect_window', + 'height=570, width=520'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue new file mode 100644 index 0000000000..e40740a3a4 --- /dev/null +++ b/packages/client/src/pages/settings/menu.vue @@ -0,0 +1,117 @@ +<template> +<FormBase> + <FormTextarea v-model="items" tall manual-save> + <span>{{ $ts.menu }}</span> + <template #desc><button class="_textButton" @click="addItem">{{ $ts.addItem }}</button></template> + </FormTextarea> + + <FormRadios v-model="menuDisplay"> + <template #desc>{{ $ts.display }}</template> + <option value="sideFull">{{ $ts._menuDisplay.sideFull }}</option> + <option value="sideIcon">{{ $ts._menuDisplay.sideIcon }}</option> + <option value="top">{{ $ts._menuDisplay.top }}</option> + <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ $ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> + </FormRadios> + + <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; +import { unisonReload } from '@/scripts/unison-reload'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormRadios, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.menu, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', + }, + menuDef: menuDef, + items: defaultStore.state.menu.join('\n'), + } + }, + + computed: { + splited(): string[] { + return this.items.trim().split('\n').filter(x => x.trim() !== ''); + }, + + menuDisplay: defaultStore.makeGetterSetter('menuDisplay') + }, + + watch: { + menuDisplay() { + this.reloadAsk(); + }, + + items() { + this.save(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async addItem() { + const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.menu.includes(k)); + const { canceled, result: item } = await os.dialog({ + type: null, + title: this.$ts.addItem, + select: { + items: [...menu.map(k => ({ + value: k, text: this.$ts[this.menuDef[k].title] + })), ...[{ + value: '-', text: this.$ts.divider + }]] + }, + showCancelButton: true + }); + if (canceled) return; + this.items = [...this.splited, item].join('\n'); + }, + + save() { + this.$store.set('menu', this.splited); + this.reloadAsk(); + }, + + reset() { + this.$store.reset('menu'); + this.items = this.$store.state.menu.join('\n'); + }, + + async reloadAsk() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + }, +}); +</script> diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue new file mode 100644 index 0000000000..4a9633a20d --- /dev/null +++ b/packages/client/src/pages/settings/mute-block.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="mute">{{ $ts.mutedUsers }}</option> + <option value="block">{{ $ts.blockedUsers }}</option> + </MkTab> + <div v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormGroup> + <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </FormLink> + </FormGroup> + </template> + </MkPagination> + </div> + <div v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormGroup> + <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </FormLink> + </FormGroup> + </template> + </MkPagination> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkTab, + FormInfo, + FormBase, + FormGroup, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.muteAndBlock, + icon: 'fas fa-ban', + bg: 'var(--bg)', + }, + tab: 'mute', + mutingPagination: { + endpoint: 'mute/list', + limit: 10, + }, + blockingPagination: { + endpoint: 'blocking/list', + limit: 10, + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + userPage + } +}); +</script> diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue new file mode 100644 index 0000000000..7de10a182c --- /dev/null +++ b/packages/client/src/pages/settings/notifications.vue @@ -0,0 +1,77 @@ +<template> +<FormBase> + <FormLink @click="configure">{{ $ts.notificationSetting }}</FormLink> + <FormGroup> + <FormButton @click="readAllNotifications">{{ $ts.markAsReadAllNotifications }}</FormButton> + <FormButton @click="readAllUnreadNotes">{{ $ts.markAsReadAllUnreadNotes }}</FormButton> + <FormButton @click="readAllMessagingMessages">{{ $ts.markAsReadAllTalkMessages }}</FormButton> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { notificationTypes } from 'misskey-js'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.notifications, + icon: 'fas fa-bell', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + readAllUnreadNotes() { + os.api('i/read-all-unread-notes'); + }, + + readAllMessagingMessages() { + os.api('i/read-all-messaging-messages'); + }, + + readAllNotifications() { + os.api('notifications/mark-all-as-read'); + }, + + configure() { + const includingTypes = notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x)); + os.popup(import('@/components/notification-setting-window.vue'), { + includingTypes, + showGlobalToggle: false, + }, { + done: async (res) => { + const { includingTypes: value } = res; + await os.apiWithDialog('i/update', { + mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)), + }).then(i => { + this.$i.mutingNotificationTypes = i.mutingNotificationTypes; + }); + } + }, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue new file mode 100644 index 0000000000..fbc895a07d --- /dev/null +++ b/packages/client/src/pages/settings/other.vue @@ -0,0 +1,97 @@ +<template> +<FormBase> + <FormLink to="/settings/update">Misskey Update</FormLink> + + <FormSwitch :value="$i.injectFeaturedNote" @update:modelValue="onChangeInjectFeaturedNote"> + {{ $ts.showFeaturedNotesInTimeline }} + </FormSwitch> + + <FormSwitch v-model="reportError">{{ $ts.sendErrorReports }}<template #desc>{{ $ts.sendErrorReportsDescription }}</template></FormSwitch> + + <FormLink to="/settings/account-info">{{ $ts.accountInfo }}</FormLink> + <FormLink to="/settings/experimental-features">{{ $ts.experimentalFeatures }}</FormLink> + + <FormGroup> + <template #label>{{ $ts.developer }}</template> + <FormSwitch v-model="debug" @update:modelValue="changeDebug"> + DEBUG MODE + </FormSwitch> + <template v-if="debug"> + <FormButton @click="taskmanager">Task Manager</FormButton> + </template> + </FormGroup> + + <FormLink to="/settings/registry"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.registry }}</FormLink> + + <FormLink to="/bios" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>BIOS</FormLink> + <FormLink to="/cli" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>CLI</FormLink> + + <FormLink to="/settings/delete-account"><template #icon><i class="fas fa-exclamation-triangle"></i></template>{{ $ts.closeAccount }}</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { debug } from '@/config'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.other, + icon: 'fas fa-ellipsis-h', + bg: 'var(--bg)', + }, + debug, + } + }, + + computed: { + reportError: defaultStore.makeGetterSetter('reportError'), + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + changeDebug(v) { + console.log(v); + localStorage.setItem('debug', v.toString()); + unisonReload(); + }, + + onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v + }); + }, + + taskmanager() { + os.popup(import('@/components/taskmanager.vue'), { + }, {}, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue new file mode 100644 index 0000000000..9958f98f58 --- /dev/null +++ b/packages/client/src/pages/settings/plugin.install.vue @@ -0,0 +1,147 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts._plugin.installWarn }}</FormInfo> + + <FormGroup> + <FormTextarea v-model="code" tall> + <span>{{ $ts.code }}</span> + </FormTextarea> + </FormGroup> + + <FormButton @click="install" :disabled="code == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { AiScript, parse } from '@syuilo/aiscript'; +import { serialize } from '@syuilo/aiscript/built/serializer'; +import { v4 as uuid } from 'uuid'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._plugin.install, + icon: 'fas fa-download', + bg: 'var(--bg)', + }, + code: null, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + installPlugin({ id, meta, ast, token }) { + ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ + ...meta, + id, + active: true, + configData: {}, + token: token, + ast: ast + })); + }, + + async install() { + let ast; + try { + ast = parse(this.code); + } catch (e) { + os.dialog({ + type: 'error', + text: 'Syntax error :(' + }); + return; + } + const meta = AiScript.collectMetadata(ast); + if (meta == null) { + os.dialog({ + type: 'error', + text: 'No metadata found :(' + }); + return; + } + const data = meta.get(null); + if (data == null) { + os.dialog({ + type: 'error', + text: 'No metadata found :(' + }); + return; + } + const { name, version, author, description, permissions, config } = data; + if (name == null || version == null || author == null) { + os.dialog({ + type: 'error', + text: 'Required property not found :(' + }); + return; + } + + const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => { + os.popup(import('@/components/token-generate-window.vue'), { + title: this.$ts.tokenRequested, + information: this.$ts.pluginTokenRequestedDescription, + initialName: name, + initialPermissions: permissions + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + res(token); + } + }, 'closed'); + }); + + this.installPlugin({ + id: uuid(), + meta: { + name, version, author, description, permissions, config + }, + token, + ast: serialize(ast) + }); + + os.success(); + + this.$nextTick(() => { + unisonReload(); + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/plugin.manage.vue b/packages/client/src/pages/settings/plugin.manage.vue new file mode 100644 index 0000000000..3a0168d13d --- /dev/null +++ b/packages/client/src/pages/settings/plugin.manage.vue @@ -0,0 +1,115 @@ +<template> +<FormBase> + <FormGroup v-for="plugin in plugins" :key="plugin.id"> + <template #label><span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span></template> + + <FormSwitch :value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ $ts.makeActive }}</FormSwitch> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <div class="_keyValue"> + <div>{{ $ts.author }}:</div> + <div>{{ plugin.author }}</div> + </div> + <div class="_keyValue"> + <div>{{ $ts.description }}:</div> + <div>{{ plugin.description }}</div> + </div> + <div class="_keyValue"> + <div>{{ $ts.permission }}:</div> + <div>{{ plugin.permissions }}</div> + </div> + </div> + </div> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <MkButton @click="config(plugin)" inline v-if="plugin.config"><i class="fas fa-cog"></i> {{ $ts.settings }}</MkButton> + <MkButton @click="uninstall(plugin)" inline danger><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</MkButton> + </div> + </div> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkSelect, + FormSwitch, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._plugin.manage, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + plugins: ColdDeviceStorage.get('plugins'), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + uninstall(plugin) { + ColdDeviceStorage.set('plugins', this.plugins.filter(x => x.id !== plugin.id)); + os.success(); + this.$nextTick(() => { + unisonReload(); + }); + }, + + // TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする + async config(plugin) { + const config = plugin.config; + for (const key in plugin.configData) { + config[key].default = plugin.configData[key]; + } + + const { canceled, result } = await os.form(plugin.name, config); + if (canceled) return; + + const plugins = ColdDeviceStorage.get('plugins'); + plugins.find(p => p.id === plugin.id).configData = result; + ColdDeviceStorage.set('plugins', plugins); + + this.$nextTick(() => { + location.reload(); + }); + }, + + changeActive(plugin, active) { + const plugins = ColdDeviceStorage.get('plugins'); + plugins.find(p => p.id === plugin.id).active = active; + ColdDeviceStorage.set('plugins', plugins); + + this.$nextTick(() => { + location.reload(); + }); + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue new file mode 100644 index 0000000000..50e53f459f --- /dev/null +++ b/packages/client/src/pages/settings/plugin.vue @@ -0,0 +1,44 @@ +<template> +<FormBase> + <FormLink to="/settings/plugin/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._plugin.install }}</FormLink> + <FormLink to="/settings/plugin/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._plugin.manage }}<template #suffix>{{ plugins }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.plugins, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + plugins: ColdDeviceStorage.get('plugins').length, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue new file mode 100644 index 0000000000..94afba9aa4 --- /dev/null +++ b/packages/client/src/pages/settings/privacy.vue @@ -0,0 +1,120 @@ +<template> +<FormBase> + <FormGroup> + <FormSwitch v-model="isLocked" @update:modelValue="save()">{{ $ts.makeFollowManuallyApprove }}</FormSwitch> + <FormSwitch v-model="autoAcceptFollowed" :disabled="!isLocked" @update:modelValue="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch> + <template #caption>{{ $ts.lockedAccountInfo }}</template> + </FormGroup> + <FormSwitch v-model="publicReactions" @update:modelValue="save()"> + {{ $ts.makeReactionsPublic }} + <template #desc>{{ $ts.makeReactionsPublicDescription }}</template> + </FormSwitch> + <FormGroup> + <template #label>{{ $ts.ffVisibility }}</template> + <FormSelect v-model="ffVisibility"> + <option value="public">{{ $ts._ffVisibility.public }}</option> + <option value="followers">{{ $ts._ffVisibility.followers }}</option> + <option value="private">{{ $ts._ffVisibility.private }}</option> + </FormSelect> + <template #caption>{{ $ts.ffVisibilityDescription }}</template> + </FormGroup> + <FormSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> + {{ $ts.hideOnlineStatus }} + <template #desc>{{ $ts.hideOnlineStatusDescription }}</template> + </FormSwitch> + <FormSwitch v-model="noCrawle" @update:modelValue="save()"> + {{ $ts.noCrawle }} + <template #desc>{{ $ts.noCrawleDescription }}</template> + </FormSwitch> + <FormSwitch v-model="isExplorable" @update:modelValue="save()"> + {{ $ts.makeExplorable }} + <template #desc>{{ $ts.makeExplorableDescription }}</template> + </FormSwitch> + <FormSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch> + <FormGroup v-if="!rememberNoteVisibility"> + <template #label>{{ $ts.defaultNoteVisibility }}</template> + <FormSelect v-model="defaultNoteVisibility"> + <option value="public">{{ $ts._visibility.public }}</option> + <option value="home">{{ $ts._visibility.home }}</option> + <option value="followers">{{ $ts._visibility.followers }}</option> + <option value="specified">{{ $ts._visibility.specified }}</option> + </FormSelect> + <FormSwitch v-model="defaultNoteLocalOnly">{{ $ts._visibility.localOnly }}</FormSwitch> + </FormGroup> + <FormSwitch v-model="keepCw" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormGroup, + FormSwitch, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.privacy, + icon: 'fas fa-lock-open', + bg: 'var(--bg)', + }, + isLocked: false, + autoAcceptFollowed: false, + noCrawle: false, + isExplorable: false, + hideOnlineStatus: false, + publicReactions: false, + ffVisibility: 'public', + } + }, + + computed: { + defaultNoteVisibility: defaultStore.makeGetterSetter('defaultNoteVisibility'), + defaultNoteLocalOnly: defaultStore.makeGetterSetter('defaultNoteLocalOnly'), + rememberNoteVisibility: defaultStore.makeGetterSetter('rememberNoteVisibility'), + keepCw: defaultStore.makeGetterSetter('keepCw'), + }, + + created() { + this.isLocked = this.$i.isLocked; + this.autoAcceptFollowed = this.$i.autoAcceptFollowed; + this.noCrawle = this.$i.noCrawle; + this.isExplorable = this.$i.isExplorable; + this.hideOnlineStatus = this.$i.hideOnlineStatus; + this.publicReactions = this.$i.publicReactions; + this.ffVisibility = this.$i.ffVisibility; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.api('i/update', { + isLocked: !!this.isLocked, + autoAcceptFollowed: !!this.autoAcceptFollowed, + noCrawle: !!this.noCrawle, + isExplorable: !!this.isExplorable, + hideOnlineStatus: !!this.hideOnlineStatus, + publicReactions: !!this.publicReactions, + ffVisibility: this.ffVisibility, + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue new file mode 100644 index 0000000000..a7ddc6d178 --- /dev/null +++ b/packages/client/src/pages/settings/profile.vue @@ -0,0 +1,281 @@ +<template> +<FormBase> + <FormGroup> + <div class="_debobigegoItem _debobigegoPanel llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <MkAvatar class="avatar" :user="$i"/> + </div> + <FormButton @click="changeAvatar" primary>{{ $ts._profile.changeAvatar }}</FormButton> + <FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton> + </FormGroup> + + <FormInput v-model="name" :max="30" manual-save> + <span>{{ $ts._profile.name }}</span> + </FormInput> + + <FormTextarea v-model="description" :max="500" tall manual-save> + <span>{{ $ts._profile.description }}</span> + <template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template> + </FormTextarea> + + <FormInput v-model="location" manual-save> + <span>{{ $ts.location }}</span> + <template #prefix><i class="fas fa-map-marker-alt"></i></template> + </FormInput> + + <FormInput v-model="birthday" type="date" manual-save> + <span>{{ $ts.birthday }}</span> + <template #prefix><i class="fas fa-birthday-cake"></i></template> + </FormInput> + + <FormSelect v-model="lang"> + <template #label>{{ $ts.language }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </FormSelect> + + <FormGroup> + <FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton> + <template #caption>{{ $ts._profile.metadataDescription }}</template> + </FormGroup> + + <FormSwitch v-model="isCat">{{ $ts.flagAsCat }}<template #desc>{{ $ts.flagAsCatDescription }}</template></FormSwitch> + + <FormSwitch v-model="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch> + + <FormSwitch v-model="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { host, langs } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormSelect, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.profile, + icon: 'fas fa-user', + bg: 'var(--bg)', + }, + host, + langs, + name: null, + description: null, + birthday: null, + lang: null, + location: null, + fieldName0: null, + fieldValue0: null, + fieldName1: null, + fieldValue1: null, + fieldName2: null, + fieldValue2: null, + fieldName3: null, + fieldValue3: null, + avatarId: null, + bannerId: null, + isBot: false, + isCat: false, + alwaysMarkNsfw: false, + saving: false, + } + }, + + created() { + this.name = this.$i.name; + this.description = this.$i.description; + this.location = this.$i.location; + this.birthday = this.$i.birthday; + this.lang = this.$i.lang; + this.avatarId = this.$i.avatarId; + this.bannerId = this.$i.bannerId; + this.isBot = this.$i.isBot; + this.isCat = this.$i.isCat; + this.alwaysMarkNsfw = this.$i.alwaysMarkNsfw; + + this.fieldName0 = this.$i.fields[0] ? this.$i.fields[0].name : null; + this.fieldValue0 = this.$i.fields[0] ? this.$i.fields[0].value : null; + this.fieldName1 = this.$i.fields[1] ? this.$i.fields[1].name : null; + this.fieldValue1 = this.$i.fields[1] ? this.$i.fields[1].value : null; + this.fieldName2 = this.$i.fields[2] ? this.$i.fields[2].name : null; + this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null; + this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null; + this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null; + + this.$watch('name', this.save); + this.$watch('description', this.save); + this.$watch('location', this.save); + this.$watch('birthday', this.save); + this.$watch('lang', this.save); + this.$watch('isBot', this.save); + this.$watch('isCat', this.save); + this.$watch('alwaysMarkNsfw', this.save); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + changeAvatar(e) { + selectFile(e.currentTarget || e.target, this.$ts.avatar).then(file => { + os.api('i/update', { + avatarId: file.id, + }); + }); + }, + + changeBanner(e) { + selectFile(e.currentTarget || e.target, this.$ts.banner).then(file => { + os.api('i/update', { + bannerId: file.id, + }); + }); + }, + + async editMetadata() { + const { canceled, result } = await os.form(this.$ts._profile.metadata, { + fieldName0: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 1', + default: this.fieldName0, + }, + fieldValue0: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 1', + default: this.fieldValue0, + }, + fieldName1: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 2', + default: this.fieldName1, + }, + fieldValue1: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 2', + default: this.fieldValue1, + }, + fieldName2: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 3', + default: this.fieldName2, + }, + fieldValue2: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 3', + default: this.fieldValue2, + }, + fieldName3: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 4', + default: this.fieldName3, + }, + fieldValue3: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 4', + default: this.fieldValue3, + }, + }); + if (canceled) return; + + this.fieldName0 = result.fieldName0; + this.fieldValue0 = result.fieldValue0; + this.fieldName1 = result.fieldName1; + this.fieldValue1 = result.fieldValue1; + this.fieldName2 = result.fieldName2; + this.fieldValue2 = result.fieldValue2; + this.fieldName3 = result.fieldName3; + this.fieldValue3 = result.fieldValue3; + + const fields = [ + { name: this.fieldName0, value: this.fieldValue0 }, + { name: this.fieldName1, value: this.fieldValue1 }, + { name: this.fieldName2, value: this.fieldValue2 }, + { name: this.fieldName3, value: this.fieldValue3 }, + ]; + + os.api('i/update', { + fields, + }).then(i => { + os.success(); + }).catch(err => { + os.dialog({ + type: 'error', + text: err.id + }); + }); + }, + + save() { + this.saving = true; + + os.apiWithDialog('i/update', { + name: this.name || null, + description: this.description || null, + location: this.location || null, + birthday: this.birthday || null, + lang: this.lang || null, + isBot: !!this.isBot, + isCat: !!this.isCat, + alwaysMarkNsfw: !!this.alwaysMarkNsfw, + }).then(i => { + this.saving = false; + this.$i.avatarId = i.avatarId; + this.$i.avatarUrl = i.avatarUrl; + this.$i.bannerId = i.bannerId; + this.$i.bannerUrl = i.bannerUrl; + }).catch(err => { + this.saving = false; + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.llvierxe { + position: relative; + height: 150px; + background-size: cover; + background-position: center; + + > * { + pointer-events: none; + } + + > .avatar { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: block; + width: 72px; + height: 72px; + margin: auto; + box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5); + } +} +</style> diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue new file mode 100644 index 0000000000..905a3e4957 --- /dev/null +++ b/packages/client/src/pages/settings/reaction.vue @@ -0,0 +1,152 @@ +<template> +<FormBase> + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.reactionSettingDescription }}</div> + <div class="_debobigegoPanel"> + <XDraggable class="zoaiodol" v-model="reactions" :item-key="item => item" animation="150" delay="100" delay-on-touch-only="true"> + <template #item="{element}"> + <button class="_button item" @click="remove(element, $event)"> + <MkEmoji :emoji="element" :normal="true"/> + </button> + </template> + <template #footer> + <button class="_button add" @click="chooseEmoji"><i class="fas fa-plus"></i></button> + </template> + </XDraggable> + </div> + <div class="_debobigegoCaption">{{ $ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ $ts.preview }}</button></div> + </div> + + <FormRadios v-model="reactionPickerWidth"> + <template #desc>{{ $ts.width }}</template> + <option :value="1">{{ $ts.small }}</option> + <option :value="2">{{ $ts.medium }}</option> + <option :value="3">{{ $ts.large }}</option> + </FormRadios> + <FormRadios v-model="reactionPickerHeight"> + <template #desc>{{ $ts.height }}</template> + <option :value="1">{{ $ts.small }}</option> + <option :value="2">{{ $ts.medium }}</option> + <option :value="3">{{ $ts.large }}</option> + </FormRadios> + <FormButton @click="preview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + <FormButton danger @click="setDefault"><i class="fas fa-undo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XDraggable from 'vuedraggable'; +import FormInput from '@/components/debobigego/input.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormInput, + FormButton, + FormBase, + FormRadios, + XDraggable, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.reaction, + icon: 'fas fa-laugh', + action: { + icon: 'fas fa-eye', + handler: this.preview + }, + bg: 'var(--bg)', + }, + reactions: JSON.parse(JSON.stringify(this.$store.state.reactions)), + } + }, + + computed: { + reactionPickerWidth: defaultStore.makeGetterSetter('reactionPickerWidth'), + reactionPickerHeight: defaultStore.makeGetterSetter('reactionPickerHeight'), + }, + + watch: { + reactions: { + handler() { + this.save(); + }, + deep: true + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + this.$store.set('reactions', this.reactions); + }, + + remove(reaction, ev) { + os.popupMenu([{ + text: this.$ts.remove, + action: () => { + this.reactions = this.reactions.filter(x => x !== reaction) + } + }], ev.currentTarget || ev.target); + }, + + preview(ev) { + os.popup(import('@/components/emoji-picker-dialog.vue'), { + asReactionPicker: true, + src: ev.currentTarget || ev.target, + }, {}, 'closed'); + }, + + async setDefault() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.resetAreYouSure, + showCancelButton: true + }); + if (canceled) return; + + this.reactions = JSON.parse(JSON.stringify(this.$store.def.reactions.default)); + }, + + chooseEmoji(ev) { + os.pickEmoji(ev.currentTarget || ev.target, { + showPinned: false + }).then(emoji => { + if (!this.reactions.includes(emoji)) { + this.reactions.push(emoji); + } + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.zoaiodol { + padding: 16px; + + > .item { + display: inline-block; + padding: 8px; + cursor: move; + } + + > .add { + display: inline-block; + padding: 8px; + } +} +</style> diff --git a/packages/client/src/pages/settings/registry.keys.vue b/packages/client/src/pages/settings/registry.keys.vue new file mode 100644 index 0000000000..ca4d01cc94 --- /dev/null +++ b/packages/client/src/pages/settings/registry.keys.vue @@ -0,0 +1,114 @@ +<template> +<FormBase> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts._registry.domain }}</template> + <template #value>{{ $ts.system }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="keys"> + <template #label>{{ $ts._registry.keys }}</template> + <FormLink v-for="key in keys" :to="`/settings/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> + </FormGroup> + + <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + props: { + scope: { + required: true + } + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + keys: null, + } + }, + + watch: { + scope() { + this.fetch(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + this.fetch(); + }, + + methods: { + fetch() { + os.api('i/registry/keys-with-type', { + scope: this.scope + }).then(keys => { + this.keys = Object.entries(keys).sort((a, b) => a[0].localeCompare(b[0])); + }); + }, + + async createKey() { + const { canceled, result } = await os.form(this.$ts._registry.createKey, { + key: { + type: 'string', + label: this.$ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: this.$ts.value, + }, + scope: { + type: 'string', + label: this.$ts._registry.scope, + default: this.scope.join('/') + } + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + this.fetch(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/registry.value.vue b/packages/client/src/pages/settings/registry.value.vue new file mode 100644 index 0000000000..36f989dbc5 --- /dev/null +++ b/packages/client/src/pages/settings/registry.value.vue @@ -0,0 +1,149 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</FormInfo> + + <template v-if="value"> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts._registry.domain }}</template> + <template #value>{{ $ts.system }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.key }}</template> + <template #value>{{ xKey }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormTextarea tall v-model="valueForEditor" class="_monospace" style="tab-size: 2;"> + <span>{{ $ts.value }} (JSON)</span> + </FormTextarea> + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormGroup> + + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime :time="value.updatedAt" mode="detail"/></template> + </FormKeyValueView> + + <FormButton danger @click="del"><i class="fas fa-trash"></i> {{ $ts.delete }}</FormButton> + </template> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormInfo, + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormTextarea, + FormGroup, + FormKeyValueView, + }, + + props: { + scope: { + required: true + }, + xKey: { + required: true + }, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + value: null, + valueForEditor: null, + } + }, + + watch: { + key() { + this.fetch(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + this.fetch(); + }, + + methods: { + fetch() { + os.api('i/registry/get-detail', { + scope: this.scope, + key: this.xKey + }).then(value => { + this.value = value; + this.valueForEditor = JSON5.stringify(this.value.value, null, '\t'); + }); + }, + + save() { + try { + JSON5.parse(this.valueForEditor); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts.invalidValue + }); + return; + } + + os.dialog({ + type: 'warning', + text: this.$ts.saveConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: this.scope, + key: this.xKey, + value: JSON5.parse(this.valueForEditor) + }); + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/remove', { + scope: this.scope, + key: this.xKey + }); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/registry.vue b/packages/client/src/pages/settings/registry.vue new file mode 100644 index 0000000000..0bfed0ddb7 --- /dev/null +++ b/packages/client/src/pages/settings/registry.vue @@ -0,0 +1,90 @@ +<template> +<FormBase> + <FormGroup v-if="scopes"> + <template #label>{{ $ts.system }}</template> + <FormLink v-for="scope in scopes" :to="`/settings/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink> + </FormGroup> + <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + scopes: null, + } + }, + + created() { + this.fetch(); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + fetch() { + os.api('i/registry/scopes').then(scopes => { + this.scopes = scopes.slice().sort((a, b) => a.join('/').localeCompare(b.join('/'))); + }); + }, + + async createKey() { + const { canceled, result } = await os.form(this.$ts._registry.createKey, { + key: { + type: 'string', + label: this.$ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: this.$ts.value, + }, + scope: { + type: 'string', + label: this.$ts._registry.scope, + } + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + this.fetch(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue new file mode 100644 index 0000000000..4d81bf1b9e --- /dev/null +++ b/packages/client/src/pages/settings/security.vue @@ -0,0 +1,158 @@ +<template> +<FormBase> + <X2fa/> + <FormLink to="/settings/2fa"><template #icon><i class="fas fa-mobile-alt"></i></template>{{ $ts.twoStepAuthentication }}</FormLink> + <FormButton primary @click="change()">{{ $ts.changePassword }}</FormButton> + <FormPagination :pagination="pagination"> + <template #label>{{ $ts.signinHistory }}</template> + <template #default="{items}"> + <div class="_debobigegoPanel timnmucd" v-for="item in items" :key="item.id"> + <header> + <i v-if="item.success" class="fas fa-check icon succ"></i> + <i v-else class="fas fa-times-circle icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> + </template> + </FormPagination> + <FormGroup> + <FormButton danger @click="regenerateToken"><i class="fas fa-sync-alt"></i> {{ $ts.regenerateLoginToken }}</FormButton> + <template #caption>{{ $ts.regenerateLoginTokenDescription }}</template> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormPagination from '@/components/debobigego/pagination.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormPagination, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.security, + icon: 'fas fa-lock', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/signin-history', + limit: 5, + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async change() { + const { canceled: canceled1, result: currentPassword } = await os.dialog({ + title: this.$ts.currentPassword, + input: { + type: 'password' + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.dialog({ + title: this.$ts.newPassword, + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.dialog({ + title: this.$ts.newPasswordRetype, + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.dialog({ + type: 'error', + text: this.$ts.retypedNotMatch + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword + }); + }, + + regenerateToken() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.timnmucd { + padding: 16px; + + > header { + display: flex; + align-items: center; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.succ { + color: var(--success); + } + + &.fail { + color: var(--error); + } + } + + > .ip { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 12px; + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue new file mode 100644 index 0000000000..ea3daced9d --- /dev/null +++ b/packages/client/src/pages/settings/sounds.vue @@ -0,0 +1,155 @@ +<template> +<FormBase> + <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05"> + <template #label><i class="fas fa-volume-icon"></i> {{ $ts.masterVolume }}</template> + </FormRange> + + <FormGroup> + <template #label>{{ $ts.sounds }}</template> + <FormButton v-for="type in Object.keys(sounds)" :key="type" :center="false" @click="edit(type)"> + {{ $t('_sfx.' + type) }} + <template #suffix>{{ sounds[type].type || $ts.none }}</template> + <template #suffixIcon><i class="fas fa-chevron-down"></i></template> + </FormButton> + </FormGroup> + + <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormRange from '@/components/debobigego/range.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { playFile } from '@/scripts/sound'; +import * as symbols from '@/symbols'; + +const soundsTypes = [ + null, + 'syuilo/up', + 'syuilo/down', + 'syuilo/pope1', + 'syuilo/pope2', + 'syuilo/waon', + 'syuilo/popo', + 'syuilo/triple', + 'syuilo/poi1', + 'syuilo/poi2', + 'syuilo/pirori', + 'syuilo/pirori-wet', + 'syuilo/pirori-square-wet', + 'syuilo/square-pico', + 'syuilo/reverved', + 'syuilo/ryukyu', + 'syuilo/kick', + 'syuilo/snare', + 'syuilo/queue-jammed', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba', + 'noizenecio/kick_gaba2', +]; + +export default defineComponent({ + components: { + FormSelect, + FormButton, + FormBase, + FormRange, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.sounds, + icon: 'fas fa-music', + bg: 'var(--bg)', + }, + sounds: {}, + } + }, + + computed: { + masterVolume: { // TODO: (外部)関数にcomputedを使うのはアレなので直す + get() { return ColdDeviceStorage.get('sound_masterVolume'); }, + set(value) { ColdDeviceStorage.set('sound_masterVolume', value); } + }, + volumeIcon() { + return this.masterVolume === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'; + } + }, + + created() { + this.sounds.note = ColdDeviceStorage.get('sound_note'); + this.sounds.noteMy = ColdDeviceStorage.get('sound_noteMy'); + this.sounds.notification = ColdDeviceStorage.get('sound_notification'); + this.sounds.chat = ColdDeviceStorage.get('sound_chat'); + this.sounds.chatBg = ColdDeviceStorage.get('sound_chatBg'); + this.sounds.antenna = ColdDeviceStorage.get('sound_antenna'); + this.sounds.channel = ColdDeviceStorage.get('sound_channel'); + this.sounds.reversiPutBlack = ColdDeviceStorage.get('sound_reversiPutBlack'); + this.sounds.reversiPutWhite = ColdDeviceStorage.get('sound_reversiPutWhite'); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async edit(type) { + const { canceled, result } = await os.form(this.$t('_sfx.' + type), { + type: { + type: 'enum', + enum: soundsTypes.map(x => ({ + value: x, + label: x == null ? this.$ts.none : x, + })), + label: this.$ts.sound, + default: this.sounds[type].type, + }, + volume: { + type: 'range', + mim: 0, + max: 1, + step: 0.05, + label: this.$ts.volume, + default: this.sounds[type].volume + }, + listen: { + type: 'button', + content: this.$ts.listen, + action: (_, values) => { + playFile(values.type, values.volume); + } + } + }); + if (canceled) return; + + const v = { + type: result.type, + volume: result.volume, + }; + + ColdDeviceStorage.set('sound_' + type, v); + this.sounds[type] = v; + }, + + reset() { + for (const sound of Object.keys(this.sounds)) { + const v = ColdDeviceStorage.default['sound_' + sound]; + ColdDeviceStorage.set('sound_' + sound, v); + this.sounds[sound] = v; + } + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue new file mode 100644 index 0000000000..59ad3ad9b7 --- /dev/null +++ b/packages/client/src/pages/settings/theme.install.vue @@ -0,0 +1,105 @@ +<template> +<FormBase> + <FormGroup> + <FormTextarea v-model="installThemeCode"> + <span>{{ $ts._theme.code }}</span> + </FormTextarea> + <FormButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + </FormGroup> + + <FormButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { applyTheme, validateTheme } from '@/scripts/theme'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { addTheme, getThemes } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._theme.install, + icon: 'fas fa-download', + bg: 'var(--bg)', + }, + installThemeCode: null, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + parseThemeCode(code) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return false; + } + if (!validateTheme(theme)) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return false; + } + if (getThemes().some(t => t.id === theme.id)) { + os.dialog({ + type: 'info', + text: this.$ts._theme.alreadyInstalled + }); + return false; + } + + return theme; + }, + + preview(code) { + const theme = this.parseThemeCode(code); + if (theme) applyTheme(theme, false); + }, + + async install(code) { + const theme = this.parseThemeCode(code); + if (!theme) return; + await addTheme(theme); + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..8a24481ae2 --- /dev/null +++ b/packages/client/src/pages/settings/theme.manage.vue @@ -0,0 +1,105 @@ +<template> +<FormBase> + <FormSelect v-model="selectedThemeId"> + <template #label>{{ $ts.theme }}</template> + <optgroup :label="$ts._theme.installedThemes"> + <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts._theme.builtinThemes"> + <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <template v-if="selectedTheme"> + <FormInput readonly :modelValue="selectedTheme.author"> + <span>{{ $ts.author }}</span> + </FormInput> + <FormTextarea readonly :modelValue="selectedTheme.desc" v-if="selectedTheme.desc"> + <span>{{ $ts._theme.description }}</span> + </FormTextarea> + <FormTextarea readonly tall :modelValue="selectedThemeCode"> + <span>{{ $ts._theme.code }}</span> + <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $ts.copy }}</button></template> + </FormTextarea> + <FormButton @click="uninstall()" danger v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</FormButton> + </template> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { Theme, builtinThemes } from '@/scripts/theme'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { getThemes, removeTheme } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormInput, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._theme.manage, + icon: 'fas fa-folder-open', + bg: 'var(--bg)', + }, + installedThemes: getThemes(), + builtinThemes, + selectedThemeId: null, + } + }, + + computed: { + themes(): Theme[] { + return this.builtinThemes.concat(this.installedThemes); + }, + + selectedTheme() { + if (this.selectedThemeId == null) return null; + return this.themes.find(x => x.id === this.selectedThemeId); + }, + + selectedThemeCode() { + if (this.selectedTheme == null) return null; + return JSON5.stringify(this.selectedTheme, null, '\t'); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + copyThemeCode() { + copyToClipboard(this.selectedThemeCode); + os.success(); + }, + + uninstall() { + removeTheme(this.selectedTheme); + this.installedThemes = this.installedThemes.filter(t => t.id !== this.selectedThemeId); + this.selectedThemeId = null; + os.success(); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue new file mode 100644 index 0000000000..a9cca40f3c --- /dev/null +++ b/packages/client/src/pages/settings/theme.vue @@ -0,0 +1,424 @@ +<template> +<FormBase> + <FormGroup> + <div class="rfqxtzch _debobigegoItem _debobigegoPanel"> + <div class="darkMode"> + <div class="toggleWrapper"> + <input type="checkbox" class="dn" id="dn" v-model="darkMode"/> + <label for="dn" class="toggle"> + <span class="before">{{ $ts.light }}</span> + <span class="after">{{ $ts.dark }}</span> + <span class="toggle__handler"> + <span class="crater crater--1"></span> + <span class="crater crater--2"></span> + <span class="crater crater--3"></span> + </span> + <span class="star star--1"></span> + <span class="star star--2"></span> + <span class="star star--3"></span> + <span class="star star--4"></span> + <span class="star star--5"></span> + <span class="star star--6"></span> + </label> + </div> + </div> + </div> + <FormSwitch v-model="syncDeviceDarkMode">{{ $ts.syncDeviceDarkMode }}</FormSwitch> + </FormGroup> + + <template v-if="darkMode"> + <FormSelect v-model="darkThemeId"> + <template #label>{{ $ts.themeForDarkMode }}</template> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="lightThemeId"> + <template #label>{{ $ts.themeForLightMode }}</template> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </template> + <template v-else> + <FormSelect v-model="lightThemeId"> + <template #label>{{ $ts.themeForLightMode }}</template> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="darkThemeId"> + <template #label>{{ $ts.themeForDarkMode }}</template> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </template> + + <FormButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $ts.setWallpaper }}</FormButton> + <FormButton primary v-else @click="wallpaper = null">{{ $ts.removeWallpaper }}</FormButton> + + <FormGroup> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="fas fa-globe"></i></template>{{ $ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._theme.install }}</FormLink> + </FormGroup> + + <FormGroup> + <FormLink to="/theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }}</FormLink> + <!--<FormLink to="/advanced-theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }} ({{ $ts.advanced }})</FormLink>--> + </FormGroup> + + <FormLink to="/settings/theme/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineComponent, onActivated, onMounted, ref, watch } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { builtinThemes } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { ColdDeviceStorage } from '@/store'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import { fetchThemes, getThemes } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSwitch, + FormSelect, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + setup(props, { emit }) { + const INFO = { + title: i18n.locale.theme, + icon: 'fas fa-palette', + bg: 'var(--bg)', + }; + + const installedThemes = ref(getThemes()); + const themes = computed(() => builtinThemes.concat(installedThemes.value)); + const darkThemes = computed(() => themes.value.filter(t => t.base == 'dark' || t.kind == 'dark')); + const lightThemes = computed(() => themes.value.filter(t => t.base == 'light' || t.kind == 'light')); + const darkTheme = ColdDeviceStorage.ref('darkTheme'); + const darkThemeId = computed({ + get() { + return darkTheme.value.id; + }, + set(id) { + ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id)) + } + }); + const lightTheme = ColdDeviceStorage.ref('lightTheme'); + const lightThemeId = computed({ + get() { + return lightTheme.value.id; + }, + set(id) { + ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id)) + } + }); + const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); + const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); + const wallpaper = ref(localStorage.getItem('wallpaper')); + const themesCount = installedThemes.value.length; + + watch(syncDeviceDarkMode, () => { + if (syncDeviceDarkMode) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } + }); + + watch(wallpaper, () => { + if (wallpaper.value == null) { + localStorage.removeItem('wallpaper'); + } else { + localStorage.setItem('wallpaper', wallpaper.value); + } + location.reload(); + }); + + onMounted(() => { + emit('info', INFO); + }); + + onActivated(() => { + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); + }); + + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); + + return { + [symbols.PAGE_INFO]: INFO, + darkThemes, + lightThemes, + darkThemeId, + lightThemeId, + darkMode, + syncDeviceDarkMode, + themesCount, + wallpaper, + setWallpaper(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + wallpaper.value = file.url; + }); + }, + }; + } +}); +</script> + +<style lang="scss" scoped> +.rfqxtzch { + padding: 16px; + + > .darkMode { + position: relative; + padding: 32px 0; + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + .toggleWrapper { + position: absolute; + top: 50%; + left: 50%; + overflow: hidden; + padding: 0 100px; + transform: translate3d(-50%, -50%, 0); + + input { + position: absolute; + left: -99em; + } + } + + .toggle { + cursor: pointer; + display: inline-block; + position: relative; + width: 90px; + height: 50px; + background-color: #83D8FF; + border-radius: 90px - 6; + transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + + > .before, > .after { + position: absolute; + top: 15px; + font-size: 18px; + transition: color 1s ease; + } + + > .before { + left: -70px; + color: var(--accent); + } + + > .after { + right: -68px; + color: var(--fg); + } + } + + .toggle__handler { + display: inline-block; + position: relative; + z-index: 1; + top: 3px; + left: 3px; + width: 50px - 6; + height: 50px - 6; + background-color: #FFCF96; + border-radius: 50px; + box-shadow: 0 2px 6px rgba(0,0,0,.3); + transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important; + transform: rotate(-45deg); + + .crater { + position: absolute; + background-color: #E8CDA5; + opacity: 0; + transition: opacity 200ms ease-in-out !important; + border-radius: 100%; + } + + .crater--1 { + top: 18px; + left: 10px; + width: 4px; + height: 4px; + } + + .crater--2 { + top: 28px; + left: 22px; + width: 6px; + height: 6px; + } + + .crater--3 { + top: 10px; + left: 25px; + width: 8px; + height: 8px; + } + } + + .star { + position: absolute; + background-color: #ffffff; + transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + border-radius: 50%; + } + + .star--1 { + top: 10px; + left: 35px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--2 { + top: 18px; + left: 28px; + z-index: 1; + width: 30px; + height: 3px; + } + + .star--3 { + top: 27px; + left: 40px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--4, + .star--5, + .star--6 { + opacity: 0; + transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--4 { + top: 16px; + left: 11px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + .star--5 { + top: 32px; + left: 17px; + z-index: 0; + width: 3px; + height: 3px; + transform: translate3d(3px,0,0); + } + + .star--6 { + top: 36px; + left: 28px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + input:checked { + + .toggle { + background-color: #749DD6; + + > .before { + color: var(--fg); + } + + > .after { + color: var(--accent); + } + + .toggle__handler { + background-color: #FFE5B5; + transform: translate3d(40px, 0, 0) rotate(0); + + .crater { opacity: 1; } + } + + .star--1 { + width: 2px; + height: 2px; + } + + .star--2 { + width: 4px; + height: 4px; + transform: translate3d(-5px, 0, 0); + } + + .star--3 { + width: 2px; + height: 2px; + transform: translate3d(-7px, 0, 0); + } + + .star--4, + .star--5, + .star--6 { + opacity: 1; + transform: translate3d(0,0,0); + } + + .star--4 { + transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--5 { + transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--6 { + transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/settings/update.vue b/packages/client/src/pages/settings/update.vue new file mode 100644 index 0000000000..aa4050fe9f --- /dev/null +++ b/packages/client/src/pages/settings/update.vue @@ -0,0 +1,95 @@ +<template> +<FormBase> + <template v-if="meta"> + <FormInfo v-if="version === meta.version">{{ $ts.youAreRunningUpToDateClient }}</FormInfo> + <FormInfo v-else warn>{{ $ts.newVersionOfClientAvailable }}</FormInfo> + </template> + <FormGroup> + <template #label>{{ instanceName }}</template> + <FormKeyValueView> + <template #key>{{ $ts.currentVersion }}</template> + <template #value>{{ version }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestVersion }}</template> + <template #value v-if="meta">{{ meta.version }}</template> + <template #value v-else><MkEllipsis/></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <template #label>Misskey</template> + <FormKeyValueView> + <template #key>{{ $ts.latestVersion }}</template> + <template #value v-if="releases">{{ releases[0].tag_name }}</template> + <template #value v-else><MkEllipsis/></template> + </FormKeyValueView> + <template #caption v-if="releases"><MkTime :time="releases[0].published_at" mode="detail"/></template> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { version, instanceName } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Misskey Update', + icon: 'fas fa-sync-alt', + bg: 'var(--bg)', + }, + version, + instanceName, + releases: null, + meta: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('meta', { + detail: false + }).then(meta => { + this.meta = meta; + localStorage.setItem('v', meta.version); + }); + + fetch('https://api.github.com/repos/misskey-dev/misskey/releases', { + method: 'GET', + }) + .then(res => res.json()) + .then(res => { + this.releases = res; + }); + }, + + methods: { + } +}); +</script> diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue new file mode 100644 index 0000000000..c2162bb1f3 --- /dev/null +++ b/packages/client/src/pages/settings/word-mute.vue @@ -0,0 +1,110 @@ +<template> +<div> + <MkTab v-model="tab"> + <option value="soft">{{ $ts._wordMute.soft }}</option> + <option value="hard">{{ $ts._wordMute.hard }}</option> + </MkTab> + <FormBase> + <div class="_debobigegoItem"> + <div v-show="tab === 'soft'"> + <FormInfo>{{ $ts._wordMute.softDescription }}</FormInfo> + <FormTextarea v-model="softMutedWords"> + <span>{{ $ts._wordMute.muteWords }}</span> + <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + </div> + <div v-show="tab === 'hard'"> + <FormInfo>{{ $ts._wordMute.hardDescription }}</FormInfo> + <FormTextarea v-model="hardMutedWords"> + <span>{{ $ts._wordMute.muteWords }}</span> + <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + <FormKeyValueView v-if="hardWordMutedNotesCount != null"> + <template #key>{{ $ts._wordMute.mutedNotes }}</template> + <template #value>{{ number(hardWordMutedNotesCount) }}</template> + </FormKeyValueView> + </div> + </div> + <FormButton @click="save()" primary inline :disabled="!changed"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormBase> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormKeyValueView, + MkTab, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.wordMute, + icon: 'fas fa-comment-slash', + bg: 'var(--bg)', + }, + tab: 'soft', + softMutedWords: '', + hardMutedWords: '', + hardWordMutedNotesCount: null, + changed: false, + } + }, + + watch: { + softMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + hardMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + }, + + async created() { + this.softMutedWords = this.$store.state.mutedWords.map(x => x.join(' ')).join('\n'); + this.hardMutedWords = this.$i.mutedWords.map(x => x.join(' ')).join('\n'); + + this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async save() { + this.$store.set('mutedWords', this.softMutedWords.trim().split('\n').map(x => x.trim().split(' '))); + await os.api('i/update', { + mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.changed = false; + }, + + number + } +}); +</script> diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue new file mode 100644 index 0000000000..c0af44fdd1 --- /dev/null +++ b/packages/client/src/pages/share.vue @@ -0,0 +1,184 @@ +<template> +<div class=""> + <section class="_section"> + <div class="_content"> + <XPostForm + v-if="state === 'writing'" + fixed + :share="true" + :initial-text="initialText" + :initial-visibility="visibility" + :initial-files="files" + :initial-local-only="localOnly" + :reply="reply" + :renote="renote" + :visible-users="visibleUsers" + @posted="state = 'posted'" + class="_panel" + /> + <MkButton v-else-if="state === 'posted'" primary @click="close()" class="close">{{ $ts.close }}</MkButton> + </div> + </section> +</div> +</template> + +<script lang="ts"> +// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html + +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import XPostForm from '@/components/post-form.vue'; +import * as os from '@/os'; +import { noteVisibilities } from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import * as symbols from '@/symbols'; +import * as Misskey from 'misskey-js'; + +export default defineComponent({ + components: { + XPostForm, + MkButton, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.share, + icon: 'fas fa-share-alt' + }, + state: 'fetching' as 'fetching' | 'writing' | 'posted', + + title: null as string | null, + initialText: null as string | null, + reply: null as Misskey.entities.Note | null, + renote: null as Misskey.entities.Note | null, + visibility: null as string | null, + localOnly: null as boolean | null, + files: [] as Misskey.entities.DriveFile[], + visibleUsers: [] as Misskey.entities.User[], + } + }, + + async created() { + const urlParams = new URLSearchParams(window.location.search); + + this.title = urlParams.get('title'); + const text = urlParams.get('text'); + const url = urlParams.get('url'); + + let noteText = ''; + if (this.title) noteText += `[ ${this.title} ]\n`; + // Googleニュース対策 + if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, ''); + else if (text && this.title !== text) noteText += `${text}\n`; + if (url) noteText += `${url}`; + this.initialText = noteText.trim(); + + const visibility = urlParams.get('visibility'); + if (noteVisibilities.includes(visibility)) { + this.visibility = visibility; + } + + if (this.visibility === 'specified') { + const visibleUserIds = urlParams.get('visibleUserIds'); + const visibleAccts = urlParams.get('visibleAccts'); + await Promise.all( + [ + ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), + ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []) + ] + // TypeScriptの指示通りに変換する + .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) + .map(q => os.api('users/show', q) + .then(user => { + this.visibleUsers.push(user); + }, () => { + console.error(`Invalid user query: ${JSON.stringify(q)}`); + }) + ) + ); + } + + const localOnly = urlParams.get('localOnly'); + if (localOnly === '0') this.localOnly = false; + else if (localOnly === '1') this.localOnly = true; + + try { + //#region Reply + const replyId = urlParams.get('replyId'); + const replyUri = urlParams.get('replyUri'); + if (replyId) { + this.reply = await os.api('notes/show', { + noteId: replyId + }); + } else if (replyUri) { + const obj = await os.api('ap/show', { + uri: replyUri + }); + if (obj.type === 'Note') { + this.reply = obj.object; + } + } + //#endregion + + //#region Renote + const renoteId = urlParams.get('renoteId'); + const renoteUri = urlParams.get('renoteUri'); + if (renoteId) { + this.renote = await os.api('notes/show', { + noteId: renoteId + }); + } else if (renoteUri) { + const obj = await os.api('ap/show', { + uri: renoteUri + }); + if (obj.type === 'Note') { + this.renote = obj.object; + } + } + //#endregion + + //#region Drive files + const fileIds = urlParams.get('fileIds'); + if (fileIds) { + await Promise.all( + fileIds.split(',') + .map(fileId => os.api('drive/files/show', { fileId }) + .then(file => { + this.files.push(file); + }, () => { + console.error(`Failed to fetch a file ${fileId}`); + }) + ) + ); + } + //#endregion + } catch (e) { + os.dialog({ + type: 'error', + title: e.message, + text: e.name + }); + } + + this.state = 'writing'; + }, + + methods: { + close() { + window.close(); + + // 閉じなければ100ms後タイムラインに + setTimeout(() => { + this.$router.push('/'); + }, 100); + } + } +}); +</script> + +<style lang="scss" scoped> +.close { + margin: 16px auto; +} +</style> diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue new file mode 100644 index 0000000000..3bbc9938dd --- /dev/null +++ b/packages/client/src/pages/signup-complete.vue @@ -0,0 +1,50 @@ +<template> +<div> + {{ $ts.processing }} +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + + }, + + props: { + code: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.signup, + icon: 'fas fa-user' + }, + } + }, + + mounted() { + os.apiWithDialog('signup-pending', { + code: this.code, + }).then(res => { + login(res.i, '/'); + }); + }, + + methods: { + + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue new file mode 100644 index 0000000000..f4709659e3 --- /dev/null +++ b/packages/client/src/pages/tag.vue @@ -0,0 +1,57 @@ +<template> +<div class="_section"> + <XNotes ref="notes" class="_content" :pagination="pagination" @before="before" @after="after"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XNotes from '@/components/notes.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes + }, + + props: { + tag: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.tag, + icon: 'fas fa-hashtag' + }, + pagination: { + endpoint: 'notes/search-by-tag', + limit: 10, + params: () => ({ + tag: this.tag, + }) + }, + }; + }, + + watch: { + tag() { + (this.$refs.notes as any).reload(); + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/pages/test.vue b/packages/client/src/pages/test.vue new file mode 100644 index 0000000000..9dd9ae5e0c --- /dev/null +++ b/packages/client/src/pages/test.vue @@ -0,0 +1,259 @@ +<template> +<div class="_section"> + <div class="_content"> + <div class="_card _gap"> + <div class="_title">Dialog</div> + <div class="_content"> + <MkInput v-model="dialogTitle"> + <template #label>Title</template> + </MkInput> + <MkInput v-model="dialogBody"> + <template #label>Body</template> + </MkInput> + <MkRadio v-model="dialogType" value="info">Info</MkRadio> + <MkRadio v-model="dialogType" value="success">Success</MkRadio> + <MkRadio v-model="dialogType" value="warning">Warn</MkRadio> + <MkRadio v-model="dialogType" value="error">Error</MkRadio> + <MkSwitch v-model="dialogCancel"> + <span>With cancel button</span> + </MkSwitch> + <MkSwitch v-model="dialogCancelByBgClick"> + <span>Can cancel by modal bg click</span> + </MkSwitch> + <MkSwitch v-model="dialogInput"> + <span>With input field</span> + </MkSwitch> + <MkButton @click="showDialog()">Show</MkButton> + </div> + <div class="_content"> + <code>Result: {{ dialogResult }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Form</div> + <div class="_content"> + <MkInput v-model="formTitle"> + <template #label>Title</template> + </MkInput> + <MkTextarea v-model="formForm"> + <template #label>Form</template> + </MkTextarea> + <MkButton @click="form()">Show</MkButton> + </div> + <div class="_content"> + <code>Result: {{ formResult }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">MFM</div> + <div class="_content"> + <MkTextarea v-model="mfm"> + <template #label>MFM</template> + </MkTextarea> + </div> + <div class="_content"> + <Mfm :text="mfm"/> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">selectDriveFile</div> + <div class="_content"> + <MkSwitch v-model="selectDriveFileMultiple"> + <span>Multiple</span> + </MkSwitch> + <MkButton @click="selectDriveFile()">selectDriveFile</MkButton> + </div> + <div class="_content"> + <code>Result: {{ JSON.stringify(selectDriveFileResult) }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">selectDriveFolder</div> + <div class="_content"> + <MkSwitch v-model="selectDriveFolderMultiple"> + <span>Multiple</span> + </MkSwitch> + <MkButton @click="selectDriveFolder()">selectDriveFolder</MkButton> + </div> + <div class="_content"> + <code>Result: {{ JSON.stringify(selectDriveFolderResult) }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">selectUser</div> + <div class="_content"> + <MkButton @click="selectUser()">selectUser</MkButton> + </div> + <div class="_content"> + <code>Result: {{ user }}</code> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Notification</div> + <div class="_content"> + <MkInput v-model="notificationIconUrl"> + <template #label>Icon URL</template> + </MkInput> + <MkInput v-model="notificationHeader"> + <template #label>Header</template> + </MkInput> + <MkTextarea v-model="notificationBody"> + <template #label>Body</template> + </MkTextarea> + <MkButton @click="createNotification()">createNotification</MkButton> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Waiting dialog</div> + <div class="_content"> + <MkButton inline @click="openWaitingDialog()">icon only</MkButton> + <MkButton inline @click="openWaitingDialog('Doing')">with text</MkButton> + </div> + </div> + + <div class="_card _gap"> + <div class="_title">Messaging window</div> + <div class="_content"> + <MkButton @click="messagingWindowOpen()">open</MkButton> + </div> + </div> + + <MkButton @click="resetTutorial()">Reset tutorial</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkRadio from '@/components/form/radio.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkSwitch, + MkTextarea, + MkRadio, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'TEST', + icon: 'fas fa-exclamation-triangle' + }, + dialogTitle: 'Hello', + dialogBody: 'World!', + dialogType: 'info', + dialogCancel: false, + dialogCancelByBgClick: true, + dialogInput: false, + dialogResult: null, + formTitle: 'Test form', + formForm: JSON.stringify({ + 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' + }, + qux: { + type: 'string', + multiline: true, + default: 'Misskey makes\nyou happy.', + label: 'Multiline string' + }, + }, null, '\t'), + formResult: null, + mfm: '', + selectDriveFileMultiple: false, + selectDriveFolderMultiple: false, + selectDriveFileResult: null, + selectDriveFolderResult: null, + user: null, + notificationIconUrl: null, + notificationHeader: '', + notificationBody: '', + } + }, + + methods: { + async showDialog() { + this.dialogResult = null; + this.dialogResult = await os.dialog({ + type: this.dialogType, + title: this.dialogTitle, + text: this.dialogBody, + showCancelButton: this.dialogCancel, + cancelableByBgClick: this.dialogCancelByBgClick, + input: this.dialogInput ? {} : null + }); + }, + + async form() { + this.formResult = null; + this.formResult = await os.form(this.formTitle, JSON.parse(this.formForm)); + }, + + async selectDriveFile() { + this.selectDriveFileResult = null; + this.selectDriveFileResult = await os.selectDriveFile(this.selectDriveFileMultiple); + }, + + async selectDriveFolder() { + this.selectDriveFolderResult = null; + this.selectDriveFolderResult = await os.selectDriveFolder(this.selectDriveFolderMultiple); + }, + + async selectUser() { + this.user = null; + this.user = await os.selectUser(); + }, + + async createNotification() { + os.api('notifications/create', { + header: this.notificationHeader, + body: this.notificationBody, + icon: this.notificationIconUrl, + }); + }, + + messagingWindowOpen() { + os.pageWindow('/my/messaging'); + }, + + openWaitingDialog(text?) { + const promise = new Promise((resolve, reject) => { + setTimeout(resolve, 2000); + }); + os.promiseDialog(promise, null, null, text); + }, + + resetTutorial() { + this.$store.set('tutorial', 0); + }, + } +}); +</script> diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue new file mode 100644 index 0000000000..d1a892629b --- /dev/null +++ b/packages/client/src/pages/theme-editor.vue @@ -0,0 +1,306 @@ +<template> +<FormBase class="cwepdizn"> + <div class="_debobigegoItem colorPicker"> + <div class="_debobigegoLabel">{{ $ts.backgroundColor }}</div> + <div class="_debobigegoPanel colors"> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.color }"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.color }"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + </div> + </div> + <div class="_debobigegoItem colorPicker"> + <div class="_debobigegoLabel">{{ $ts.accentColor }}</div> + <div class="_debobigegoPanel colors"> + <div class="row"> + <button v-for="color in accentColors" :key="color" @click="setAccentColor(color)" class="color rounded _button" :class="{ active: theme.props.accent === color }"> + <div class="preview" :style="{ background: color }"></div> + </button> + </div> + </div> + </div> + <div class="_debobigegoItem colorPicker"> + <div class="_debobigegoLabel">{{ $ts.textColor }}</div> + <div class="_debobigegoPanel colors"> + <div class="row"> + <button v-for="color in fgColors" :key="color" @click="setFgColor(color)" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }"> + <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> + </button> + </div> + </div> + </div> + + <FormGroup v-if="codeEnabled"> + <FormTextarea v-model="themeCode" tall> + <span>{{ $ts._theme.code }}</span> + </FormTextarea> + <FormButton @click="applyThemeCode" primary>{{ $ts.apply }}</FormButton> + </FormGroup> + <FormButton v-else @click="codeEnabled = true"><i class="fas fa-code"></i> {{ $ts.editCode }}</FormButton> + + <FormGroup v-if="descriptionEnabled"> + <FormTextarea v-model="description"> + <span>{{ $ts._theme.description }}</span> + </FormTextarea> + </FormGroup> + <FormButton v-else @click="descriptionEnabled = true">{{ $ts.addDescription }}</FormButton> + + <FormGroup> + <FormButton @click="showPreview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + <FormButton @click="saveAs" primary><i class="fas fa-save"></i> {{ $ts.saveAs }}</FormButton> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import * as tinycolor from 'tinycolor2'; +import { v4 as uuid} from 'uuid'; +import * as JSON5 from 'json5'; + +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormGroup from '@/components/debobigego/group.vue'; + +import { Theme, applyTheme, validateTheme, darkTheme, lightTheme } from '@/scripts/theme'; +import { host } from '@/config'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { addTheme } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormGroup, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.themeEditor, + icon: 'fas fa-palette', + }, + theme: { + base: 'light', + props: lightTheme.props + } as Theme, + codeEnabled: false, + descriptionEnabled: false, + description: null, + themeCode: null, + bgColors: [ + { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, + { color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' }, + { color: '#e9eff0', kind: 'light', forPreview: '#bfe3e8' }, + { color: '#f0e9ee', kind: 'light', forPreview: '#f1d1e8' }, + { color: '#dce2e0', kind: 'light', forPreview: '#a4dccc' }, + { color: '#e2e0dc', kind: 'light', forPreview: '#d8c7a5' }, + { color: '#d5dbe0', kind: 'light', forPreview: '#b0cae0' }, + { color: '#dad5d5', kind: 'light', forPreview: '#d6afaf' }, + { color: '#2b2b2b', kind: 'dark', forPreview: '#444444' }, + { color: '#362e29', kind: 'dark', forPreview: '#735c4d' }, + { color: '#303629', kind: 'dark', forPreview: '#506d2f' }, + { color: '#293436', kind: 'dark', forPreview: '#258192' }, + { color: '#2e2936', kind: 'dark', forPreview: '#504069' }, + { color: '#252722', kind: 'dark', forPreview: '#3c462f' }, + { color: '#212525', kind: 'dark', forPreview: '#303e3e' }, + { color: '#191919', kind: 'dark', forPreview: '#272727' }, + ], + accentColors: ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83'], + fgColors: [ + { color: 'none', forLight: '#5f5f5f', forDark: '#dadada', forPreview: null }, + { color: 'red', forLight: '#7f6666', forDark: '#e4d1d1', forPreview: '#ca4343' }, + { color: 'yellow', forLight: '#736955', forDark: '#e0d5c0', forPreview: '#d49923' }, + { color: 'green', forLight: '#586d5b', forDark: '#d1e4d4', forPreview: '#4cbd5c' }, + { color: 'cyan', forLight: '#5d7475', forDark: '#d1e3e4', forPreview: '#2abdc3' }, + { color: 'blue', forLight: '#676880', forDark: '#d1d2e4', forPreview: '#7275d8' }, + { color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' }, + ], + changed: false, + } + }, + + created() { + this.$watch('theme', this.apply, { deep: true }); + window.addEventListener('beforeunload', this.beforeunload); + }, + + beforeUnmount() { + window.removeEventListener('beforeunload', this.beforeunload); + }, + + async beforeRouteLeave(to, from) { + if (this.changed && !(await this.leaveConfirm())) { + return false; + } + }, + + methods: { + beforeunload(e: BeforeUnloadEvent) { + if (this.changed) { + e.preventDefault(); + e.returnValue = ''; + } + }, + + async leaveConfirm(): Promise<boolean> { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.leaveConfirm, + showCancelButton: true + }); + return !canceled; + }, + + showPreview() { + os.pageWindow('preview'); + }, + + setBgColor(color) { + if (this.theme.base != color.kind) { + const base = color.kind === 'dark' ? darkTheme : lightTheme; + for (const prop of Object.keys(base.props)) { + if (prop === 'accent') continue; + if (prop === 'fg') continue; + this.theme.props[prop] = base.props[prop]; + } + } + this.theme.base = color.kind; + this.theme.props.bg = color.color; + + if (this.theme.props.fg) { + const matchedFgColor = this.fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(this.theme.props.fg).toRgbString())); + if (matchedFgColor) this.setFgColor(matchedFgColor); + } + }, + + setAccentColor(color) { + this.theme.props.accent = color; + }, + + setFgColor(color) { + this.theme.props.fg = this.theme.base === 'light' ? color.forLight : color.forDark; + }, + + apply() { + this.themeCode = JSON5.stringify(this.theme, null, '\t'); + applyTheme(this.theme, false); + this.changed = true; + }, + + applyThemeCode() { + let parsed; + + try { + parsed = JSON5.parse(this.themeCode); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return; + } + + this.theme = parsed; + }, + + async saveAs() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts.name, + input: { + allowEmpty: false + } + }); + if (canceled) return; + + this.theme.id = uuid(); + this.theme.name = name; + this.theme.author = `@${this.$i.username}@${toUnicode(host)}`; + if (this.description) this.theme.desc = this.description; + addTheme(this.theme); + applyTheme(this.theme); + if (this.$store.state.darkMode) { + ColdDeviceStorage.set('darkTheme', this.theme); + } else { + ColdDeviceStorage.set('lightTheme', this.theme); + } + this.changed = false; + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: this.theme.name }) + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.cwepdizn { + max-width: 800px; + margin: 0 auto; + + > .colorPicker { + > .colors { + padding: 32px; + text-align: center; + + > .row { + > .color { + display: inline-block; + position: relative; + width: 64px; + height: 64px; + border-radius: 8px; + + > .preview { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 42px; + height: 42px; + border-radius: 4px; + box-shadow: 0 2px 4px rgb(0 0 0 / 30%); + transition: transform 0.15s ease; + } + + &:hover { + > .preview { + transform: scale(1.1); + } + } + + &.active { + box-shadow: 0 0 0 2px var(--divider) inset; + } + + &.rounded { + border-radius: 999px; + + > .preview { + border-radius: 999px; + } + } + + &.char { + line-height: 42px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/timeline.tutorial.vue b/packages/client/src/pages/timeline.tutorial.vue new file mode 100644 index 0000000000..4d6dd0af41 --- /dev/null +++ b/packages/client/src/pages/timeline.tutorial.vue @@ -0,0 +1,131 @@ +<template> +<div class="_card tbkwesmv"> + <div class="_title"><i class="fas fa-info-circle"></i> {{ $ts._tutorial.title }}</div> + <div class="_content" v-if="tutorial === 0"> + <div>{{ $ts._tutorial.step1_1 }}</div> + <div>{{ $ts._tutorial.step1_2 }}</div> + <div>{{ $ts._tutorial.step1_3 }}</div> + </div> + <div class="_content" v-else-if="tutorial === 1"> + <div>{{ $ts._tutorial.step2_1 }}</div> + <div>{{ $ts._tutorial.step2_2 }}</div> + <MkA class="_link" to="/settings/profile">{{ $ts.editProfile }}</MkA> + </div> + <div class="_content" v-else-if="tutorial === 2"> + <div>{{ $ts._tutorial.step3_1 }}</div> + <div>{{ $ts._tutorial.step3_2 }}</div> + <div>{{ $ts._tutorial.step3_3 }}</div> + <small>{{ $ts._tutorial.step3_4 }}</small> + </div> + <div class="_content" v-else-if="tutorial === 3"> + <div>{{ $ts._tutorial.step4_1 }}</div> + <div>{{ $ts._tutorial.step4_2 }}</div> + </div> + <div class="_content" v-else-if="tutorial === 4"> + <div>{{ $ts._tutorial.step5_1 }}</div> + <I18n :src="$ts._tutorial.step5_2" tag="div"> + <template #featured> + <MkA class="_link" to="/featured">{{ $ts.featured }}</MkA> + </template> + <template #explore> + <MkA class="_link" to="/explore">{{ $ts.explore }}</MkA> + </template> + </I18n> + <div>{{ $ts._tutorial.step5_3 }}</div> + <small>{{ $ts._tutorial.step5_4 }}</small> + </div> + <div class="_content" v-else-if="tutorial === 5"> + <div>{{ $ts._tutorial.step6_1 }}</div> + <div>{{ $ts._tutorial.step6_2 }}</div> + <div>{{ $ts._tutorial.step6_3 }}</div> + </div> + <div class="_content" v-else-if="tutorial === 6"> + <div>{{ $ts._tutorial.step7_1 }}</div> + <I18n :src="$ts._tutorial.step7_2" tag="div"> + <template #help> + <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ $ts.help }}</a> + </template> + </I18n> + <div>{{ $ts._tutorial.step7_3 }}</div> + </div> + + <div class="_footer navigation"> + <div class="step"> + <button class="arrow _button" @click="tutorial--" :disabled="tutorial === 0"> + <i class="fas fa-chevron-left"></i> + </button> + <span>{{ tutorial + 1 }} / 7</span> + <button class="arrow _button" @click="tutorial++" :disabled="tutorial === 6"> + <i class="fas fa-chevron-right"></i> + </button> + </div> + <MkButton class="ok" @click="tutorial = -1" primary v-if="tutorial === 6"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton> + <MkButton class="ok" @click="tutorial++" primary v-else><i class="fas fa-check"></i> {{ $ts.next }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + + data() { + return { + } + }, + + computed: { + tutorial: { + get() { return this.$store.reactiveState.tutorial.value || 0; }, + set(value) { this.$store.set('tutorial', value); } + }, + }, +}); +</script> + +<style lang="scss" scoped> +.tbkwesmv { + > ._content { + > small { + opacity: 0.7; + } + } + + > .navigation { + display: flex; + flex-direction: row; + align-items: baseline; + + > .step { + > .arrow { + padding: 4px; + + &:disabled { + opacity: 0.5; + } + + &:first-child { + padding-right: 8px; + } + + &:last-child { + padding-left: 8px; + } + } + + > span { + margin: 0 4px; + } + } + + > .ok { + margin-left: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue new file mode 100644 index 0000000000..911d6f5c6a --- /dev/null +++ b/packages/client/src/pages/timeline.vue @@ -0,0 +1,225 @@ +<template> +<div class="cmuxhskf" v-size="{ min: [800] }" v-hotkey.global="keymap"> + <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/> + <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/> + + <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline ref="tl" class="tl" + :key="src" + :src="src" + :sound="true" + @before="before()" + @after="after()" + @queue="queueUpdated" + /> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import Progress from '@/scripts/loading'; +import XTimeline from '@/components/timeline.vue'; +import XPostForm from '@/components/post-form.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + name: 'timeline', + + components: { + XTimeline, + XTutorial: defineAsyncComponent(() => import('./timeline.tutorial.vue')), + XPostForm, + }, + + data() { + return { + src: 'home', + queue: 0, + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.timeline, + icon: this.src === 'local' ? 'fas fa-comments' : this.src === 'social' ? 'fas fa-share-alt' : this.src === 'global' ? 'fas fa-globe' : 'fas fa-home', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-list-ul', + text: this.$ts.lists, + handler: this.chooseList + }, { + icon: 'fas fa-satellite', + text: this.$ts.antennas, + handler: this.chooseAntenna + }, { + icon: 'fas fa-satellite-dish', + text: this.$ts.channel, + handler: this.chooseChannel + }, { + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }], + tabs: [{ + active: this.src === 'home', + title: this.$ts._timelines.home, + icon: 'fas fa-home', + iconOnly: true, + onClick: () => { this.src = 'home'; this.saveSrc(); }, + }, { + active: this.src === 'local', + title: this.$ts._timelines.local, + icon: 'fas fa-comments', + iconOnly: true, + onClick: () => { this.src = 'local'; this.saveSrc(); }, + }, { + active: this.src === 'social', + title: this.$ts._timelines.social, + icon: 'fas fa-share-alt', + iconOnly: true, + onClick: () => { this.src = 'social'; this.saveSrc(); }, + }, { + active: this.src === 'global', + title: this.$ts._timelines.global, + icon: 'fas fa-globe', + iconOnly: true, + onClick: () => { this.src = 'global'; this.saveSrc(); }, + }], + })), + }; + }, + + computed: { + keymap(): any { + return { + 't': this.focus + }; + }, + + isLocalTimelineAvailable(): boolean { + return !this.$instance.disableLocalTimeline || this.$i.isModerator || this.$i.isAdmin; + }, + + isGlobalTimelineAvailable(): boolean { + return !this.$instance.disableGlobalTimeline || this.$i.isModerator || this.$i.isAdmin; + }, + }, + + watch: { + src() { + this.showNav = false; + }, + }, + + created() { + this.src = this.$store.state.tl.src; + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + queueUpdated(q) { + this.queue = q; + }, + + top() { + scroll(this.$el, { top: 0 }); + }, + + async chooseList(ev) { + const lists = await os.api('users/lists/list'); + const items = lists.map(list => ({ + type: 'link', + text: list.name, + to: `/timeline/list/${list.id}` + })); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + + async chooseAntenna(ev) { + const antennas = await os.api('antennas/list'); + const items = antennas.map(antenna => ({ + type: 'link', + text: antenna.name, + indicate: antenna.hasUnreadNote, + to: `/timeline/antenna/${antenna.id}` + })); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + + async chooseChannel(ev) { + const channels = await os.api('channels/followed'); + const items = channels.map(channel => ({ + type: 'link', + text: channel.name, + indicate: channel.hasUnreadNote, + to: `/channels/${channel.id}` + })); + os.popupMenu(items, ev.currentTarget || ev.target); + }, + + saveSrc() { + this.$store.set('tl', { + src: this.src, + }); + }, + + async timetravel() { + const { canceled, result: date } = await os.dialog({ + title: this.$ts.date, + input: { + type: 'date' + } + }); + if (canceled) return; + + this.$refs.tl.timetravel(new Date(date)); + }, + + focus() { + (this.$refs.tl as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.cmuxhskf { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .post-form { + border-radius: var(--radius); + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/user-ap-info.vue b/packages/client/src/pages/user-ap-info.vue new file mode 100644 index 0000000000..6253faa242 --- /dev/null +++ b/packages/client/src/pages/user-ap-info.vue @@ -0,0 +1,124 @@ +<template> +<FormBase> + <FormSuspense :p="apPromiseFactory" v-slot="{ result: ap }"> + <FormGroup> + <template #label>ActivityPub</template> + <FormKeyValueView> + <template #key>Type</template> + <template #value><span class="_monospace">{{ ap.type }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>URI</template> + <template #value><span class="_monospace">{{ ap.id }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>URL</template> + <template #value><span class="_monospace">{{ ap.url }}</span></template> + </FormKeyValueView> + <FormGroup> + <FormKeyValueView> + <template #key>Inbox</template> + <template #value><span class="_monospace">{{ ap.inbox }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Shared Inbox</template> + <template #value><span class="_monospace">{{ ap.sharedInbox || ap.endpoints.sharedInbox }}</span></template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>Outbox</template> + <template #value><span class="_monospace">{{ ap.outbox }}</span></template> + </FormKeyValueView> + </FormGroup> + <FormTextarea readonly tall code pre :value="ap.publicKey.publicKeyPem"> + <span>Public Key</span> + </FormTextarea> + <FormKeyValueView> + <template #key>Discoverable</template> + <template #value>{{ ap.discoverable ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>ManuallyApprovesFollowers</template> + <template #value>{{ ap.manuallyApprovesFollowers ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormObjectView tall :value="ap"> + <span>Raw</span> + </FormObjectView> + <FormGroup> + <FormLink :to="`https://${user.host}/.well-known/webfinger?resource=acct:${user.username}`" external>WebFinger</FormLink> + </FormGroup> + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormObjectView from '@/components/debobigego/object-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; +import { url } from '@/config'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + }, + + props: { + userId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.userInfo, + icon: 'fas fa-info-circle' + }, + user: null, + apPromiseFactory: null, + } + }, + + mounted() { + this.fetch(); + }, + + methods: { + number, + bytes, + + async fetch() { + this.user = await os.api('users/show', { + userId: this.userId + }); + + this.apPromiseFactory = () => os.api('ap/get', { + uri: this.user.uri || `${url}/users/${this.user.id}` + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue new file mode 100644 index 0000000000..b77d879a7e --- /dev/null +++ b/packages/client/src/pages/user-info.vue @@ -0,0 +1,245 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <div class="_debobigegoItem aeakzknw"> + <MkAvatar class="avatar" :user="user" :show-indicator="true"/> + </div> + + <FormLink :to="userPage(user)">Profile</FormLink> + + <FormGroup> + <FormKeyValueView> + <template #key>Acct</template> + <template #value><span class="_monospace">{{ acct(user) }}</span></template> + </FormKeyValueView> + + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="iAmModerator"> + <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:modelValue="toggleModerator" v-model="moderator">{{ $ts.moderator }}</FormSwitch> + <FormSwitch @update:modelValue="toggleSilence" v-model="silenced">{{ $ts.silence }}</FormSwitch> + <FormSwitch @update:modelValue="toggleSuspend" v-model="suspended">{{ $ts.suspend }}</FormSwitch> + </FormGroup> + + <FormGroup> + <FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> + <FormButton v-if="user.host == null && iAmModerator" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> + </FormGroup> + + <FormGroup> + <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> + + <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> + <FormKeyValueView v-else> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </FormKeyValueView> + </FormGroup> + + <FormObjectView tall :value="user"> + <span>Raw</span> + </FormObjectView> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent } from 'vue'; +import FormObjectView from '@/components/debobigego/object-view.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; +import { url } from '@/config'; +import { userPage, acct } from '@/filters/user'; + +export default defineComponent({ + components: { + FormBase, + FormTextarea, + FormSwitch, + FormObjectView, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormSuspense, + }, + + props: { + userId: { + type: String, + required: true + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => ({ + title: this.user ? acct(this.user) : this.$ts.userInfo, + icon: 'fas fa-info-circle', + actions: this.user ? [this.user.url ? { + text: this.user.url, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(this.user.url, '_blank'); + } + } : undefined].filter(x => x !== undefined) : [], + })), + init: null, + user: null, + info: null, + moderator: false, + silenced: false, + suspended: false, + } + }, + + computed: { + iAmModerator(): boolean { + return this.$i && (this.$i.isAdmin || this.$i.isModerator); + } + }, + + watch: { + userId: { + handler() { + this.init = this.createFetcher(); + }, + immediate: true + } + }, + + methods: { + number, + bytes, + userPage, + acct, + + createFetcher() { + if (this.iAmModerator) { + return () => Promise.all([os.api('users/show', { + userId: this.userId + }), os.api('admin/show-user', { + userId: this.userId + })]).then(([user, info]) => { + this.user = user; + this.info = info; + this.moderator = this.info.isModerator; + this.silenced = this.info.isSilenced; + this.suspended = this.info.isSuspended; + }); + } else { + return () => os.api('users/show', { + userId: this.userId + }).then((user) => { + this.user = user; + }); + } + }, + + refreshUser() { + this.init = this.createFetcher(); + }, + + async updateRemoteUser() { + await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id }); + this.refreshUser(); + }, + + async resetPassword() { + const { password } = await os.api('admin/reset-password', { + userId: this.user.id, + }); + + os.dialog({ + type: 'success', + text: this.$t('newPasswordIs', { password }) + }); + }, + + async toggleSilence(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm, + }); + if (confirm.canceled) { + this.silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleSuspend(v) { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm, + }); + if (confirm.canceled) { + this.suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); + await this.refreshUser(); + } + }, + + async toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); + await this.refreshUser(); + }, + + async deleteAllFiles() { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + text: this.$ts.deleteAllFilesConfirm, + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); + os.success(); + }; + await process().catch(e => { + os.dialog({ + type: 'error', + text: e.toString() + }); + }); + await this.refreshUser(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.aeakzknw { + > .avatar { + display: block; + margin: 0 auto; + width: 64px; + height: 64px; + } +} +</style> diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue new file mode 100644 index 0000000000..2fc2476fba --- /dev/null +++ b/packages/client/src/pages/user-list-timeline.vue @@ -0,0 +1,147 @@ +<template> +<div class="eqqrhokj" v-hotkey.global="keymap" v-size="{ min: [800] }"> + <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline ref="tl" class="tl" + :key="listId" + src="list" + :list="listId" + :sound="true" + @before="before()" + @after="after()" + @queue="queueUpdated" + /> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import Progress from '@/scripts/loading'; +import XTimeline from '@/components/timeline.vue'; +import { scroll } from '@/scripts/scroll'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XTimeline, + }, + + props: { + listId: { + type: String, + required: true + } + }, + + data() { + return { + list: null, + queue: 0, + [symbols.PAGE_INFO]: computed(() => this.list ? { + title: this.list.name, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', + actions: [{ + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }, { + icon: 'fas fa-cog', + text: this.$ts.settings, + handler: this.settings + }], + } : null), + }; + }, + + computed: { + keymap(): any { + return { + 't': this.focus + }; + }, + }, + + watch: { + listId: { + async handler() { + this.list = await os.api('users/lists/show', { + listId: this.listId + }); + }, + immediate: true + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + }, + + queueUpdated(q) { + this.queue = q; + }, + + top() { + scroll(this.$el, { top: 0 }); + }, + + settings() { + this.$router.push(`/my/lists/${this.listId}`); + }, + + async timetravel() { + const { canceled, result: date } = await os.dialog({ + title: this.$ts.date, + input: { + type: 'date' + } + }); + if (canceled) return; + + this.$refs.tl.timetravel(new Date(date)); + }, + + focus() { + (this.$refs.tl as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.eqqrhokj { + padding: var(--margin); + + > .new { + position: sticky; + top: calc(var(--stickyTop, 0px) + 16px); + z-index: 1000; + width: 100%; + + > button { + display: block; + margin: var(--margin) auto 0 auto; + padding: 8px 16px; + border-radius: 32px; + } + } + + > .tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; + } + + &.min-width_800px { + max-width: 800px; + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/pages/user/clips.vue b/packages/client/src/pages/user/clips.vue new file mode 100644 index 0000000000..2ec96d2286 --- /dev/null +++ b/packages/client/src/pages/user/clips.vue @@ -0,0 +1,50 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/clips', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue new file mode 100644 index 0000000000..fec4431419 --- /dev/null +++ b/packages/client/src/pages/user/follow-list.vue @@ -0,0 +1,65 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" class="mk-following-or-followers" ref="list"> + <div class="users _isolated"> + <MkUserInfo class="user" v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :user="user" :key="user.id"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkUserInfo from '@/components/user-info.vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkUserInfo, + }, + + props: { + user: { + type: Object, + required: true + }, + type: { + type: String, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + type() { + this.$refs.list.reload(); + }, + + user() { + this.$refs.list.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-following-or-followers { + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + } +} +</style> diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue new file mode 100644 index 0000000000..fb99cdff19 --- /dev/null +++ b/packages/client/src/pages/user/gallery.vue @@ -0,0 +1,56 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}"> + <div class="jrnovfpt"> + <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkGalleryPostPreview, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/gallery/posts', + limit: 6, + params: () => ({ + userId: this.user.id + }) + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.jrnovfpt { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: var(--margin); +} +</style> diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue new file mode 100644 index 0000000000..e51d6c6090 --- /dev/null +++ b/packages/client/src/pages/user/index.activity.vue @@ -0,0 +1,34 @@ +<template> +<MkContainer> + <template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> + + <div style="padding: 8px;"> + <MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :stacked="true" :detailed="false" :aspect-ratio="6"/> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; +import MkChart from '@/components/chart.vue'; + +export default defineComponent({ + components: { + MkContainer, + MkChart, + }, + props: { + user: { + type: Object, + required: true + }, + limit: { + type: Number, + required: false, + default: 40 + } + }, +}); +</script> diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue new file mode 100644 index 0000000000..4c52dceae6 --- /dev/null +++ b/packages/client/src/pages/user/index.photos.vue @@ -0,0 +1,107 @@ +<template> +<MkContainer :max-height="300" :foldable="true"> + <template #header><i class="fas fa-image" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template> + <div class="ujigsodd"> + <MkLoading v-if="fetching"/> + <div class="stream" v-if="!fetching && images.length > 0"> + <MkA v-for="image in images" + class="img" + :to="notePage(image.note)" + :key="image.id" + > + <ImgWithBlurhash :hash="image.blurhash" :src="thumbnail(image.file)" :alt="image.name" :title="image.name"/> + </MkA> + </div> + <p class="empty" v-if="!fetching && images.length == 0">{{ $ts.nothing }}</p> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import notePage from '@/filters/note'; +import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; +import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; + +export default defineComponent({ + components: { + MkContainer, + ImgWithBlurhash, + }, + props: { + user: { + type: Object, + required: true + }, + }, + data() { + return { + fetching: true, + images: [], + }; + }, + mounted() { + const image = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/apng', + 'image/vnd.mozilla.apng', + ]; + os.api('users/notes', { + userId: this.user.id, + fileType: image, + excludeNsfw: this.$store.state.nsfw !== 'ignore', + limit: 10, + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + this.images.push({ + note, + file + }); + } + } + this.fetching = false; + }); + }, + methods: { + thumbnail(image: any): string { + return this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; + }, + notePage + }, +}); +</script> + +<style lang="scss" scoped> +.ujigsodd { + padding: 8px; + + > .stream { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-gap: 6px; + + > .img { + height: 128px; + border-radius: 6px; + overflow: clip; + } + } + + > .empty { + margin: 0; + padding: 16px; + text-align: center; + + > i { + margin-right: 4px; + } + } +} +</style> diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue new file mode 100644 index 0000000000..eff38ec3c8 --- /dev/null +++ b/packages/client/src/pages/user/index.timeline.vue @@ -0,0 +1,68 @@ +<template> +<div class="yrzkoczt" v-sticky-container> + <MkTab v-model="with_" class="tab"> + <option :value="null">{{ $ts.notes }}</option> + <option value="replies">{{ $ts.notesAndReplies }}</option> + <option value="files">{{ $ts.withFiles }}</option> + </MkTab> + <XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XNotes from '@/components/notes.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNotes, + MkTab, + }, + + props: { + user: { + type: Object, + required: true, + }, + }, + + watch: { + user() { + this.$refs.timeline.reload(); + }, + + with_() { + this.$refs.timeline.reload(); + }, + }, + + data() { + return { + date: null, + with_: null, + pagination: { + endpoint: 'users/notes', + limit: 10, + params: init => ({ + userId: this.user.id, + includeReplies: this.with_ === 'replies', + withFiles: this.with_ === 'files', + untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined), + }) + } + }; + }, +}); +</script> + +<style lang="scss" scoped> +.yrzkoczt { + > .tab { + margin: calc(var(--margin) / 2) 0; + padding: calc(var(--margin) / 2) 0; + background: var(--bg); + } +} +</style> diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue new file mode 100644 index 0000000000..d2531c0d1b --- /dev/null +++ b/packages/client/src/pages/user/index.vue @@ -0,0 +1,829 @@ +<template> +<div> +<transition name="fade" mode="out-in"> + <div class="ftskorzw wide" v-if="user && narrow === false"> + <MkRemoteCaution v-if="user.host != null" :href="user.url"/> + + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style"></div> + </div> + <div class="contents"> + <div class="side _forceContainerFull_"> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="name"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <MkAcct :user="user" :detail="true" class="acct"/> + </div> + <div class="followed" v-if="$i && $i.id != user.id && user.isFollowed"><span>{{ $ts.followsYou }}</span></div> + <div class="status"> + <MkA :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ $ts.notes }}</span> + </MkA> + <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ $ts.following }}</span> + </MkA> + <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ $ts.followers }}</span> + </MkA> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ $ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl class="field" v-if="user.location"> + <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl class="field" v-if="user.birthday"> + <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div class="fields" v-if="user.fields.length > 0"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <XActivity :user="user" :key="user.id" class="_gap"/> + <XPhotos :user="user" :key="user.id" class="_gap"/> + </div> + <div class="main"> + <div class="actions"> + <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button> + <MkFollowButton v-if="!$i || $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + <template v-if="page === 'index'"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" class="note _gap" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/> + </div> + <div class="_gap"> + <XUserTimeline :user="user"/> + </div> + </template> + <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_gap"/> + <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_gap"/> + <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> + <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> + </div> + </div> + </div> + <MkSpacer v-else-if="user && narrow === true" :content-max="800"> + <div class="ftskorzw narrow" v-size="{ max: [500] }"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> --> + + <div class="profile"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + + <div class="_block main" :key="user.id"> + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <span class="followed" v-if="$i && $i.id != user.id && user.isFollowed">{{ $ts.followsYou }}</span> + <div class="actions" v-if="$i"> + <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button> + <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true" /></span> + <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ $ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl class="field" v-if="user.location"> + <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl class="field" v-if="user.birthday"> + <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div class="fields" v-if="user.fields.length > 0"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <MkA :to="userPage(user)" :class="{ active: page === 'index' }" v-click-anime> + <b>{{ number(user.notesCount) }}</b> + <span>{{ $ts.notes }}</span> + </MkA> + <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }" v-click-anime> + <b>{{ number(user.followingCount) }}</b> + <span>{{ $ts.following }}</span> + </MkA> + <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }" v-click-anime> + <b>{{ number(user.followersCount) }}</b> + <span>{{ $ts.followers }}</span> + </MkA> + </div> + </div> + </div> + + <div class="contents"> + <template v-if="page === 'index'"> + <div> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" class="note _block" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/> + </div> + <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo> + <XPhotos :user="user" :key="user.id"/> + <XActivity :user="user" :key="user.id" style="margin-top: var(--margin);"/> + </div> + <div> + <XUserTimeline :user="user"/> + </div> + </template> + <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/> + <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/> + <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/> + <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> + <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> + <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/> + </div> + </div> + </MkSpacer> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> +</transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, computed } from 'vue'; +import * as age from 's-age'; +import XUserTimeline from './index.timeline.vue'; +import XNote from '@/components/note.vue'; +import MkFollowButton from '@/components/follow-button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkRemoteCaution from '@/components/remote-caution.vue'; +import MkTab from '@/components/tab.vue'; +import MkInfo from '@/components/ui/info.vue'; +import Progress from '@/scripts/loading'; +import * as Acct from 'misskey-js/built/acct'; +import { getScrollPosition } from '@/scripts/scroll'; +import { getUserMenu } from '@/scripts/get-user-menu'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XUserTimeline, + XNote, + MkFollowButton, + MkContainer, + MkRemoteCaution, + MkFolder, + MkTab, + MkInfo, + XFollowList: defineAsyncComponent(() => import('./follow-list.vue')), + XReactions: defineAsyncComponent(() => import('./reactions.vue')), + XClips: defineAsyncComponent(() => import('./clips.vue')), + XPages: defineAsyncComponent(() => import('./pages.vue')), + XGallery: defineAsyncComponent(() => import('./gallery.vue')), + XPhotos: defineAsyncComponent(() => import('./index.photos.vue')), + XActivity: defineAsyncComponent(() => import('./index.activity.vue')), + }, + + props: { + acct: { + type: String, + required: true + }, + page: { + type: String, + required: false, + default: 'index' + } + }, + + data() { + return { + [symbols.PAGE_INFO]: computed(() => this.user ? { + icon: 'fas fa-user', + title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`, + subtitle: `@${getAcct(this.user)}`, + userName: this.user, + avatar: this.user, + path: `/@${this.user.username}`, + share: { + title: this.user.name, + }, + bg: 'var(--bg)', + tabs: [{ + active: this.page === 'index', + title: this.$ts.overview, + icon: 'fas fa-home', + onClick: () => { this.$router.push('/@' + getAcct(this.user)); }, + }, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{ + active: this.page === 'reactions', + title: this.$ts.reaction, + icon: 'fas fa-laugh', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/reactions'); }, + }] : [], { + active: this.page === 'clips', + title: this.$ts.clips, + icon: 'fas fa-paperclip', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/clips'); }, + }, { + active: this.page === 'pages', + title: this.$ts.pages, + icon: 'fas fa-file-alt', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/pages'); }, + }, { + active: this.page === 'gallery', + title: this.$ts.gallery, + icon: 'fas fa-icons', + onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/gallery'); }, + }], + } : null), + user: null, + error: null, + parallaxAnimationId: null, + narrow: null, + }; + }, + + computed: { + style(): any { + if (this.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ this.user.bannerUrl })` + }; + }, + + age(): number { + return age(this.user.birthday); + } + }, + + watch: { + acct: 'fetch' + }, + + created() { + this.fetch(); + }, + + mounted() { + window.requestAnimationFrame(this.parallaxLoop); + this.narrow = true//this.$el.clientWidth < 1000; + }, + + beforeUnmount() { + window.cancelAnimationFrame(this.parallaxAnimationId); + }, + + methods: { + getAcct, + + fetch() { + if (this.acct == null) return; + this.user = null; + Progress.start(); + os.api('users/show', Acct.parse(this.acct)).then(user => { + this.user = user; + }).catch(e => { + this.error = e; + }).finally(() => { + Progress.done(); + }); + }, + + menu(ev) { + os.popupMenu(getUserMenu(this.user), ev.currentTarget || ev.target); + }, + + parallaxLoop() { + this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop); + this.parallax(); + }, + + parallax() { + const banner = this.$refs.banner as any; + if (banner == null) return; + + const top = getScrollPosition(this.$el); + + if (top < 0) return; + + const z = 1.75; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; + }, + + pinnedNoteUpdated(oldValue, newValue) { + const i = this.user.pinnedNotes.findIndex(n => n === oldValue); + this.user.pinnedNotes[i] = newValue; + }, + + number, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.ftskorzw.wide { + + > .banner-container { + position: relative; + height: 300px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + } + + > .contents { + display: flex; + padding: 16px; + + > .side { + width: 360px; + + > .avatar { + display: block; + width: 180px; + height: 180px; + margin: -130px auto 0 auto; + } + + > .name { + padding: 16px 0px 20px 0; + text-align: center; + + > .name { + display: block; + font-size: 1.75em; + font-weight: bold; + } + } + + > .followed { + text-align: center; + + > span { + display: inline-block; + font-size: 80%; + padding: 8px 12px; + margin-bottom: 20px; + border: solid 0.5px var(--divider); + border-radius: 999px; + } + } + + > .status { + display: flex; + padding: 20px 16px; + border-top: solid 0.5px var(--divider); + font-size: 90%; + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 75%; + } + } + } + + > .description { + padding: 20px 16px; + border-top: solid 0.5px var(--divider); + font-size: 90%; + } + + > .fields { + padding: 20px 16px; + border-top: solid 0.5px var(--divider); + font-size: 90%; + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + } + } + + > .main { + flex: 1; + margin-left: var(--margin); + min-width: 0; + + > .nav { + display: flex; + align-items: center; + margin-top: var(--margin); + //font-size: 120%; + font-weight: bold; + + > .link { + display: inline-block; + padding: 15px 24px 12px 24px; + text-align: center; + border-bottom: solid 3px transparent; + + &:hover { + text-decoration: none; + } + + &.active { + color: var(--accent); + border-bottom-color: var(--accent); + } + + &:not(.active):hover { + color: var(--fgHighlighted); + } + + > .icon { + margin-right: 6px; + } + } + + > .actions { + display: flex; + align-items: center; + margin-left: auto; + + > .menu { + padding: 12px 16px; + } + } + } + } + } +} + +.ftskorzw.narrow { + box-sizing: border-box; + overflow: clip; + background: var(--bg); + + > .punished { + font-size: 0.8em; + padding: 16px; + } + + > .profile { + + > .main { + position: relative; + overflow: hidden; + + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } + + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; + + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } + + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } + } + + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } + } + } + } + } + + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 0.5px var(--divider); + + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 0.5px var(--divider); + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + + &.system > .field > .name { + } + } + + > .status { + display: flex; + padding: 24px; + border-top: solid 0.5px var(--divider); + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } + } + } + } + } + + > .contents { + > .content { + margin-bottom: var(--margin); + } + } + + &.max-width_500px { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } +} +</style> diff --git a/packages/client/src/pages/user/pages.vue b/packages/client/src/pages/user/pages.vue new file mode 100644 index 0000000000..0bf925d7d5 --- /dev/null +++ b/packages/client/src/pages/user/pages.vue @@ -0,0 +1,49 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagePreview from '@/components/page-preview.vue'; +import MkPagination from '@/components/ui/pagination.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkPagePreview, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/pages', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue new file mode 100644 index 0000000000..3ca3b2aac8 --- /dev/null +++ b/packages/client/src/pages/user/reactions.vue @@ -0,0 +1,81 @@ +<template> +<div> + <MkPagination :pagination="pagination" #default="{items}" ref="list"> + <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb"> + <div class="header"> + <MkAvatar class="avatar" :user="user"/> + <MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> + <MkTime :time="item.createdAt" class="createdAt"/> + </div> + <MkNote :note="item.note" @update:note="updated(note, $event)" :key="item.id"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkNote from '@/components/note.vue'; +import MkReactionIcon from '@/components/reaction-icon.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkNote, + MkReactionIcon, + }, + + props: { + user: { + type: Object, + required: true + }, + }, + + data() { + return { + pagination: { + endpoint: 'users/reactions', + limit: 20, + params: { + userId: this.user.id, + } + }, + }; + }, + + watch: { + user() { + this.$refs.list.reload(); + } + }, +}); +</script> + +<style lang="scss" scoped> +.afdcfbfb { + > .header { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 8px; + border-bottom: solid 2px var(--divider); + + > .avatar { + width: 24px; + height: 24px; + margin-right: 8px; + } + + > .reaction { + width: 32px; + height: 32px; + } + + > .createdAt { + margin-left: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/v.vue b/packages/client/src/pages/v.vue new file mode 100644 index 0000000000..3b1bb20861 --- /dev/null +++ b/packages/client/src/pages/v.vue @@ -0,0 +1,29 @@ +<template> +<div> + <section class="_section"> + <div class="_content" style="text-align: center;"> + <img src="/static-assets/icons/512.png" alt="" style="display: block; width: 100px; margin: 0 auto; border-radius: 16px;"/> + <div style="margin-top: 0.75em;">Misskey</div> + <div style="opacity: 0.5;">v{{ version }}</div> + </div> + </section> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { version } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Misskey', + icon: null + }, + version, + } + }, +}); +</script> diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue new file mode 100644 index 0000000000..2e0c520bc6 --- /dev/null +++ b/packages/client/src/pages/welcome.entrance.a.vue @@ -0,0 +1,320 @@ +<template> +<div class="rsqzvsbo" v-if="meta"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape1"></div> + <div class="shape2"></div> + <img src="/client-assets/misskey.svg" class="misskey"/> + <div class="emojis"> + <MkEmoji :normal="true" :no-style="true" emoji="👍"/> + <MkEmoji :normal="true" :no-style="true" emoji="❤"/> + <MkEmoji :normal="true" :no-style="true" emoji="😆"/> + <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> + <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> + </div> + <div class="main _panel"> + <div class="bg"> + <div class="fade"></div> + </div> + <div class="fg"> + <h1> + <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に --> + <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> --> + <span class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton @click="signup()" inline gradate data-cy-signup style="margin-right: 12px;">{{ $ts.signup }}</MkButton> + <MkButton @click="signin()" inline data-cy-signin>{{ $ts.login }}</MkButton> + </div> + <div class="status" v-if="onlineUsersCount && stats"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XNote from '@/components/note.vue'; +import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + MkFeaturedPhotos, + XTimeline, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8 + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); + } + }, { + text: this.$ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + } + }, null, { + text: this.$ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + } + }], ev.currentTarget || ev.target); + }, + + number + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + display: flex; + text-align: center; + min-height: 100vh; + box-sizing: border-box; + padding: 16px; + + > .bg { + position: absolute; + top: 0; + right: 0; + width: 80%; // 100%からshapeの幅を引いている + height: 100%; + } + + > .tl { + position: absolute; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + width: 500px; + height: calc(100% - 128px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + + @media (max-width: 1200px) { + display: none; + } + } + + > .shape1 { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%); + } + > .shape2 { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%); + opacity: 0.5; + } + + > .misskey { + position: absolute; + top: 42px; + left: 42px; + width: 160px; + + @media (max-width: 450px) { + width: 130px; + } + } + + > .emojis { + position: absolute; + bottom: 32px; + left: 35px; + + > * { + margin-right: 8px; + } + + @media (max-width: 1200px) { + display: none; + } + } + + > .main { + position: relative; + width: min(480px, 100%); + margin: auto auto auto 128px; + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + + @media (max-width: 1200px) { + margin: auto; + } + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 128px; + background-position: center; + background-size: cover; + opacity: 0.75; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 128px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + } + + > .fg { + position: relative; + z-index: 1; + + > h1 { + display: block; + margin: 0; + padding: 32px 32px 24px 32px; + font-size: 1.5em; + + > .logo { + vertical-align: bottom; + max-height: 120px; + max-width: min(100%, 300px); + } + } + + > .about { + padding: 0 32px; + } + + > .action { + padding: 32px; + + > * { + line-height: 28px; + } + } + + > .status { + border-top: solid 0.5px var(--divider); + padding: 32px; + font-size: 90%; + + > div { + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 0.5px var(--divider); + } + } + + > .online { + ::v-deep(b) { + color: #41b781; + } + + ::v-deep(span) { + opacity: 0.7; + } + } + } + + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue new file mode 100644 index 0000000000..efb8b09360 --- /dev/null +++ b/packages/client/src/pages/welcome.entrance.b.vue @@ -0,0 +1,236 @@ +<template> +<div class="rsqzvsbo" v-if="meta"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <XTimeline class="tl"/> + <div class="shape"></div> + <div class="main"> + <h1> + <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton class="signup" @click="signup()" inline gradate>{{ $ts.signup }}</MkButton> + <MkButton class="signin" @click="signin()" inline>{{ $ts.login }}</MkButton> + </div> + <div class="status" v-if="onlineUsersCount && stats"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + </div> + <img src="/client-assets/misskey.svg" class="misskey"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XNote from '@/components/note.vue'; +import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + XTimeline, + MkFeaturedPhotos, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8 + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); + } + }, { + text: this.$ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + } + }, null, { + text: this.$ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + } + }], ev.currentTarget || ev.target); + }, + + number + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + min-height: 100vh; + box-sizing: border-box; + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + > .tl { + position: absolute; + top: 0; + bottom: 0; + right: 64px; + margin: auto; + width: 500px; + height: calc(100% - 128px); + overflow: hidden; + -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); + } + + > .shape { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--accent); + clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%); + } + + > .misskey { + position: absolute; + bottom: 64px; + left: 64px; + width: 160px; + } + + > .main { + position: relative; + width: min(450px, 100%); + padding: 64px; + color: #fff; + font-size: 1.1em; + + @media (max-width: 1200px) { + margin: auto; + } + + > h1 { + display: block; + margin: 0 0 32px 0; + padding: 0; + + > .logo { + vertical-align: bottom; + max-height: 100px; + } + } + + > .about { + padding: 0; + } + + > .action { + margin: 32px 0; + + > * { + line-height: 32px; + } + + > .signup { + background: var(--panel); + color: var(--fg); + } + + > .signin { + background: var(--accent); + color: inherit; + } + } + + > .status { + margin: 32px 0; + border-top: solid 1px rgba(255, 255, 255, 0.5); + font-size: 90%; + + > div { + padding: 16px 0; + + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 1px rgba(255, 255, 255, 0.5); + } + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue new file mode 100644 index 0000000000..2b0ff7a31c --- /dev/null +++ b/packages/client/src/pages/welcome.entrance.c.vue @@ -0,0 +1,305 @@ +<template> +<div class="rsqzvsbo" v-if="meta"> + <div class="top"> + <MkFeaturedPhotos class="bg"/> + <div class="fade"></div> + <div class="emojis"> + <MkEmoji :normal="true" :no-style="true" emoji="👍"/> + <MkEmoji :normal="true" :no-style="true" emoji="❤"/> + <MkEmoji :normal="true" :no-style="true" emoji="😆"/> + <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> + <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> + </div> + <div class="main"> + <img src="/client-assets/misskey.svg" class="misskey"/> + <div class="form _panel"> + <div class="bg"> + <div class="fade"></div> + </div> + <div class="fg"> + <h1> + <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> + </h1> + <div class="about"> + <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + </div> + <div class="action"> + <MkButton @click="signup()" inline gradate>{{ $ts.signup }}</MkButton> + <MkButton @click="signin()" inline>{{ $ts.login }}</MkButton> + </div> + <div class="status" v-if="onlineUsersCount && stats"> + <div> + <I18n :src="$ts.nUsers" text-tag="span" class="users"> + <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> + </I18n> + <I18n :src="$ts.nNotes" text-tag="span" class="notes"> + <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> + </I18n> + </div> + <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> + </div> + <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> + </div> + </div> + <nav class="nav"> + <MkA to="/announcements">{{ $ts.announcements }}</MkA> + <MkA to="/explore">{{ $ts.explore }}</MkA> + <MkA to="/channels">{{ $ts.channel }}</MkA> + <MkA to="/featured">{{ $ts.featured }}</MkA> + </nav> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { toUnicode } from 'punycode/'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XNote from '@/components/note.vue'; +import MkFeaturedPhotos from '@/components/featured-photos.vue'; +import XTimeline from './welcome.timeline.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import number from '@/filters/number'; + +export default defineComponent({ + components: { + MkButton, + XNote, + MkFeaturedPhotos, + XTimeline, + }, + + data() { + return { + host: toUnicode(host), + instanceName, + meta: null, + stats: null, + tags: [], + onlineUsersCount: null, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + + os.api('stats').then(stats => { + this.stats = stats; + }); + + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + + os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8 + }).then(tags => { + this.tags = tags; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + }, + + showMenu(ev) { + os.popupMenu([{ + text: this.$t('aboutX', { x: instanceName }), + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); + } + }, { + text: this.$ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + } + }, null, { + text: this.$ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open(`https://misskey-hub.net/help.md`, '_blank'); + } + }], ev.currentTarget || ev.target); + }, + + number + } +}); +</script> + +<style lang="scss" scoped> +.rsqzvsbo { + > .top { + display: flex; + text-align: center; + min-height: 100vh; + box-sizing: border-box; + padding: 16px; + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + > .fade { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.25); + } + + > .emojis { + position: absolute; + bottom: 32px; + left: 35px; + + > * { + margin-right: 8px; + } + + @media (max-width: 1200px) { + display: none; + } + } + + > .main { + position: relative; + width: min(460px, 100%); + margin: auto; + + > .misskey { + width: 150px; + margin-bottom: 16px; + + @media (max-width: 450px) { + width: 130px; + } + } + + > .form { + position: relative; + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); + + > .bg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 128px; + background-position: center; + background-size: cover; + opacity: 0.75; + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 128px; + background: linear-gradient(0deg, var(--panel), var(--X15)); + } + } + + > .fg { + position: relative; + z-index: 1; + + > h1 { + display: block; + margin: 0; + padding: 32px 32px 24px 32px; + + > .logo { + vertical-align: bottom; + max-height: 120px; + } + } + + > .about { + padding: 0 32px; + } + + > .action { + padding: 32px; + + > * { + line-height: 28px; + } + } + + > .status { + border-top: solid 0.5px var(--divider); + padding: 32px; + font-size: 90%; + + > div { + > span:not(:last-child) { + padding-right: 1em; + margin-right: 1em; + border-right: solid 0.5px var(--divider); + } + } + + > .online { + ::v-deep(b) { + color: #41b781; + } + + ::v-deep(span) { + opacity: 0.7; + } + } + } + + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + } + } + } + + > .nav { + position: relative; + z-index: 2; + margin-top: 20px; + color: #fff; + text-shadow: 0 0 8px black; + font-size: 0.9em; + + > *:not(:last-child) { + margin-right: 1.5em; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue new file mode 100644 index 0000000000..8c88720cf3 --- /dev/null +++ b/packages/client/src/pages/welcome.setup.vue @@ -0,0 +1,102 @@ +<template> +<form class="mk-setup" @submit.prevent="submit()"> + <h1>Welcome to Misskey!</h1> + <div> + <p>{{ $ts.intro }}</p> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-admin-username> + <template #label>{{ $ts.username }}</template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + </MkInput> + <MkInput v-model="password" type="password" data-cy-admin-password> + <template #label>{{ $ts.password }}</template> + <template #prefix><i class="fas fa-lock"></i></template> + </MkInput> + <footer> + <MkButton primary type="submit" :disabled="submitting" data-cy-admin-ok> + {{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/> + </MkButton> + </footer> + </div> +</form> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import { host } from '@/config'; +import * as os from '@/os'; +import { login } from '@/account'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + }, + + data() { + return { + username: '', + password: '', + submitting: false, + host, + } + }, + + methods: { + submit() { + if (this.submitting) return; + this.submitting = true; + + os.api('admin/accounts/create', { + username: this.username, + password: this.password, + }).then(res => { + return login(res.token); + }).catch(() => { + this.submitting = false; + + os.dialog({ + type: 'error', + text: this.$ts.somethingHappened + }); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-setup { + border-radius: var(--radius); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: hidden; + max-width: 500px; + margin: 32px auto; + + > h1 { + margin: 0; + font-size: 1.5em; + text-align: center; + padding: 32px; + background: var(--accent); + color: #fff; + } + + > div { + padding: 32px; + background: var(--panel); + + > p { + margin-top: 0; + } + + > footer { + > * { + margin: 0 auto; + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.timeline.vue b/packages/client/src/pages/welcome.timeline.vue new file mode 100644 index 0000000000..46e3dbb5ed --- /dev/null +++ b/packages/client/src/pages/welcome.timeline.vue @@ -0,0 +1,99 @@ +<template> +<div class="civpbkhh"> + <div class="scrollbox" ref="scroll" v-bind:class="{ scroll: isScrolling }"> + <div v-for="note in notes" class="note"> + <div class="content _panel"> + <div class="body"> + <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> + <div v-if="note.files.length > 0" class="richcontent"> + <XMediaList :media-list="note.files"/> + </div> + <div v-if="note.poll"> + <XPoll :note="note" :readOnly="true" /> + </div> + </div> + <XReactionsViewer :note="note" ref="reactionsViewer"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XReactionsViewer from '@/components/reactions-viewer.vue'; +import XMediaList from '@/components/media-list.vue'; +import XPoll from '@/components/poll.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XReactionsViewer, + XMediaList, + XPoll + }, + + data() { + return { + notes: [], + isScrolling: false, + } + }, + + created() { + os.api('notes/featured').then(notes => { + this.notes = notes; + }); + }, + + updated() { + if (this.$refs.scroll.clientHeight > window.innerHeight) { + this.isScrolling = true; + } + } +}); +</script> + +<style lang="scss" scoped> +@keyframes scroll { + 0% { + transform: translate3d(0, 0, 0); + } + 5% { + transform: translate3d(0, 0, 0); + } + 75% { + transform: translate3d(0, calc(-100% + 90vh), 0); + } + 90% { + transform: translate3d(0, calc(-100% + 90vh), 0); + } +} + +.civpbkhh { + text-align: right; + + > .scrollbox { + &.scroll { + animation: scroll 45s linear infinite; + } + + > .note { + margin: 16px 0 16px auto; + + > .content { + padding: 16px; + margin: 0 0 0 auto; + max-width: max-content; + border-radius: 16px; + + > .richcontent { + min-width: 250px; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue new file mode 100644 index 0000000000..4c038b5113 --- /dev/null +++ b/packages/client/src/pages/welcome.vue @@ -0,0 +1,38 @@ +<template> +<div v-if="meta"> + <XSetup v-if="meta.requireSetup"/> + <XEntrance v-else/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XSetup from './welcome.setup.vue'; +import XEntrance from './welcome.entrance.a.vue'; +import { instanceName } from '@/config'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XSetup, + XEntrance, + }, + + data() { + return { + [symbols.PAGE_INFO]: { + title: instanceName, + icon: null + }, + meta: null + } + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + } +}); +</script> diff --git a/packages/client/src/pizzax.ts b/packages/client/src/pizzax.ts new file mode 100644 index 0000000000..396abc2418 --- /dev/null +++ b/packages/client/src/pizzax.ts @@ -0,0 +1,153 @@ +import { onUnmounted, Ref, ref, watch } from 'vue'; +import { $i } from './account'; +import { api } from './os'; + +type StateDef = Record<string, { + where: 'account' | 'device' | 'deviceAccount'; + default: any; +}>; + +type ArrayElement<A> = A extends readonly (infer T)[] ? T : never; + +export class Storage<T extends StateDef> { + public readonly key: string; + public readonly keyForLocalStorage: string; + + public readonly def: T; + + // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 + public readonly state: { [K in keyof T]: T[K]['default'] }; + public readonly reactiveState: { [K in keyof T]: Ref<T[K]['default']> }; + + constructor(key: string, def: T) { + this.key = key; + this.keyForLocalStorage = 'pizzax::' + key; + this.def = def; + + // TODO: indexedDBにする + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {}; + const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {}; + + const state = {}; + const reactiveState = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { + state[k] = deviceState[k]; + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + state[k] = registryCache[k]; + } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { + state[k] = deviceAccountState[k]; + } else { + state[k] = v.default; + if (_DEV_) console.log('Use default value', k, v.default); + } + } + for (const [k, v] of Object.entries(state)) { + reactiveState[k] = ref(v); + } + this.state = state as any; + this.reactiveState = reactiveState as any; + + if ($i) { + // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) + setTimeout(() => { + api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => { + const cache = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + state[k] = kvs[k]; + reactiveState[k].value = kvs[k]; + cache[k] = kvs[k]; + } else { + state[k] = v.default; + reactiveState[k].value = v.default; + } + } + } + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + }); + }, 1); + + // TODO: streamingのuser storage updateイベントを監視して更新 + } + } + + public set<K extends keyof T>(key: K, value: T[K]['default']): void { + if (_DEV_) console.log('set', key, value); + + this.state[key] = value; + this.reactiveState[key].value = value; + + switch (this.def[key].where) { + case 'device': { + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + deviceState[key] = value; + localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState)); + break; + } + case 'deviceAccount': { + if ($i == null) break; + const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}'); + deviceAccountState[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState)); + break; + } + case 'account': { + if ($i == null) break; + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + api('i/registry/set', { + scope: ['client', this.key], + key: key, + value: value + }); + break; + } + } + } + + public push<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): void { + const currentState = this.state[key]; + this.set(key, [...currentState, value]); + } + + public reset(key: keyof T) { + this.set(key, this.def[key].default); + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue場で設定コントロールのmodelとして使う用 + */ + public makeGetterSetter<K extends keyof T>(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]) { + const valueRef = ref(this.state[key]); + + const stop = watch(this.reactiveState[key], val => { + valueRef.value = val; + }); + + // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする + onUnmounted(() => { + stop(); + }); + + // TODO: VueのcustomRef使うと良い感じになるかも + return { + get: () => { + if (getter) { + return getter(valueRef.value); + } else { + return valueRef.value; + } + }, + set: (value: unknown) => { + const val = setter ? setter(value) : value; + this.set(key, val); + valueRef.value = val; + } + }; + } +} diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts new file mode 100644 index 0000000000..c56ee1eb25 --- /dev/null +++ b/packages/client/src/plugin.ts @@ -0,0 +1,124 @@ +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { deserialize } from '@syuilo/aiscript/built/serializer'; +import { jsToVal } from '@syuilo/aiscript/built/interpreter/util'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import { dialog } from '@/os'; +import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store'; + +const pluginContexts = new Map<string, AiScript>(); + +export function install(plugin) { + console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + + const aiscript = new AiScript(createPluginEnv({ + plugin: plugin, + storageKey: 'plugins:' + plugin.id + }), { + in: (q) => { + return new Promise(ok => { + dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + }); + + initPlugin({ plugin, aiscript }); + + aiscript.exec(deserialize(plugin.ast)); +} + +function createPluginEnv(opts) { + const config = new Map(); + for (const [k, v] of Object.entries(opts.plugin.config || {})) { + config.set(k, jsToVal(opts.plugin.configData.hasOwnProperty(k) ? opts.plugin.configData[k] : v.default)); + } + + return { + ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), + //#region Deprecated + 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + //#endregion + 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { + registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { + registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { + window.open(url.value, '_blank'); + }), + 'Plugin:config': values.OBJ(config), + }; +} + +function initPlugin({ plugin, aiscript }) { + pluginContexts.set(plugin.id, aiscript); +} + +function registerPostFormAction({ pluginId, title, handler }) { + postFormActions.push({ + title, handler: (form, update) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + update(key.value, value.value); + })]); + } + }); +} + +function registerUserAction({ pluginId, title, handler }) { + userActions.push({ + title, handler: (user) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]); + } + }); +} + +function registerNoteAction({ pluginId, title, handler }) { + noteActions.push({ + title, handler: (note) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + } + }); +} + +function registerNoteViewInterruptor({ pluginId, handler }) { + noteViewInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + } + }); +} + +function registerNotePostInterruptor({ pluginId, handler }) { + notePostInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + } + }); +} diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts new file mode 100644 index 0000000000..9b4dd162f3 --- /dev/null +++ b/packages/client/src/router.ts @@ -0,0 +1,149 @@ +import { defineAsyncComponent, markRaw } from 'vue'; +import { createRouter, createWebHistory } from 'vue-router'; +import MkLoading from '@/pages/_loading_.vue'; +import MkError from '@/pages/_error_.vue'; +import MkTimeline from '@/pages/timeline.vue'; +import { $i } from './account'; +import { ui } from '@/config'; + +const page = (path: string, ui?: string) => defineAsyncComponent({ + loader: ui ? () => import(`./ui/${ui}/pages/${path}.vue`) : () => import(`./pages/${path}.vue`), + loadingComponent: MkLoading, + errorComponent: MkError, +}); + +let indexScrollPos = 0; + +const defaultRoutes = [ + // NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる + { path: '/', name: 'index', component: $i ? MkTimeline : page('welcome') }, + { path: '/@:acct/:page?', name: 'user', component: page('user/index'), props: route => ({ acct: route.params.acct, page: route.params.page || 'index' }) }, + { path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) }, + { path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, + { path: '/@:acct/room', props: true, component: page('room/room') }, + { path: '/settings/:page(.*)?', name: 'settings', component: page('settings/index'), props: route => ({ initialPage: route.params.page || null }) }, + { path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) }, + { path: '/signup-complete/:code', component: page('signup-complete'), props: route => ({ code: route.params.code }) }, + { path: '/announcements', component: page('announcements') }, + { path: '/about', component: page('about') }, + { path: '/about-misskey', component: page('about-misskey') }, + { path: '/featured', component: page('featured') }, + { path: '/theme-editor', component: page('theme-editor') }, + { path: '/advanced-theme-editor', component: page('advanced-theme-editor') }, + { path: '/explore', component: page('explore') }, + { path: '/explore/tags/:tag', props: true, component: page('explore') }, + { path: '/federation', component: page('federation') }, + { path: '/emojis', component: page('emojis') }, + { path: '/search', component: page('search') }, + { path: '/pages', name: 'pages', component: page('pages') }, + { path: '/pages/new', component: page('page-editor/page-editor') }, + { path: '/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, + { path: '/gallery', component: page('gallery/index') }, + { path: '/gallery/new', component: page('gallery/edit') }, + { path: '/gallery/:postId/edit', component: page('gallery/edit'), props: route => ({ postId: route.params.postId }) }, + { path: '/gallery/:postId', component: page('gallery/post'), props: route => ({ postId: route.params.postId }) }, + { path: '/channels', component: page('channels') }, + { path: '/channels/new', component: page('channel-editor') }, + { path: '/channels/:channelId/edit', component: page('channel-editor'), props: true }, + { path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) }, + { path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) }, + { path: '/timeline/list/:listId', component: page('user-list-timeline'), props: route => ({ listId: route.params.listId }) }, + { path: '/timeline/antenna/:antennaId', component: page('antenna-timeline'), props: route => ({ antennaId: route.params.antennaId }) }, + { path: '/my/notifications', component: page('notifications') }, + { path: '/my/favorites', component: page('favorites') }, + { path: '/my/messages', component: page('messages') }, + { path: '/my/mentions', component: page('mentions') }, + { path: '/my/messaging', name: 'messaging', component: page('messaging/index') }, + { path: '/my/messaging/:user', component: page('messaging/messaging-room'), props: route => ({ userAcct: route.params.user }) }, + { path: '/my/messaging/group/:group', component: page('messaging/messaging-room'), props: route => ({ groupId: route.params.group }) }, + { path: '/my/drive', name: 'drive', component: page('drive') }, + { path: '/my/drive/folder/:folder', component: page('drive') }, + { path: '/my/follow-requests', component: page('follow-requests') }, + { path: '/my/lists', component: page('my-lists/index') }, + { path: '/my/lists/:list', component: page('my-lists/list') }, + { path: '/my/groups', component: page('my-groups/index') }, + { path: '/my/groups/:group', component: page('my-groups/group'), props: route => ({ groupId: route.params.group }) }, + { path: '/my/antennas', component: page('my-antennas/index') }, + { path: '/my/antennas/create', component: page('my-antennas/create') }, + { path: '/my/antennas/:antennaId', component: page('my-antennas/edit'), props: true }, + { path: '/my/clips', component: page('my-clips/index') }, + { path: '/scratchpad', component: page('scratchpad') }, + { path: '/admin/:page(.*)?', component: page('admin/index'), props: route => ({ initialPage: route.params.page || null }) }, + { path: '/admin', component: page('admin/index') }, + { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, + { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, + { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) }, + { path: '/user-ap-info/:user', component: page('user-ap-info'), props: route => ({ userId: route.params.user }) }, + { path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) }, + { path: '/games/reversi', component: page('reversi/index') }, + { path: '/games/reversi/:gameId', component: page('reversi/game'), props: route => ({ gameId: route.params.gameId }) }, + { path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') }, + { path: '/api-console', component: page('api-console') }, + { path: '/preview', component: page('preview') }, + { path: '/test', component: page('test') }, + { path: '/auth/:token', component: page('auth') }, + { path: '/miauth/:session', component: page('miauth') }, + { path: '/authorize-follow', component: page('follow') }, + { path: '/share', component: page('share') }, + { path: '/:catchAll(.*)', component: page('not-found') } +]; + +const chatRoutes = [ + { path: '/timeline', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) }, + { path: '/timeline/home', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) }, + { path: '/timeline/local', component: page('timeline', 'chat'), props: route => ({ src: 'local' }) }, + { path: '/timeline/social', component: page('timeline', 'chat'), props: route => ({ src: 'social' }) }, + { path: '/timeline/global', component: page('timeline', 'chat'), props: route => ({ src: 'global' }) }, + { path: '/channels/:channelId', component: page('channel', 'chat'), props: route => ({ channelId: route.params.channelId }) }, +]; + +function margeRoutes(routes: any[]) { + const result = defaultRoutes; + for (const route of routes) { + const found = result.findIndex(x => x.path === route.path); + if (found > -1) { + result[found] = route; + } else { + result.unshift(route); + } + } + return result; +} + +export const router = createRouter({ + history: createWebHistory(), + routes: margeRoutes(ui === 'chat' ? chatRoutes : []), + // なんかHacky + // 通常の使い方をすると scroll メソッドの behavior を設定できないため、自前で window.scroll するようにする + scrollBehavior(to) { + window._scroll = () => { // さらにHacky + if (to.name === 'index') { + window.scroll({ top: indexScrollPos, behavior: 'instant' }); + const i = setInterval(() => { + window.scroll({ top: indexScrollPos, behavior: 'instant' }); + }, 10); + setTimeout(() => { + clearInterval(i); + }, 500); + } else { + window.scroll({ top: 0, behavior: 'instant' }); + } + }; + } +}); + +router.afterEach((to, from) => { + if (from.name === 'index') { + indexScrollPos = window.scrollY; + } +}); + +export function resolve(path: string) { + const resolved = router.resolve(path); + const route = resolved.matched[0]; + return { + component: markRaw(route.components.default), + // TODO: route.propsには関数以外も入る可能性があるのでよしなにハンドリングする + props: route.props?.default ? route.props.default(resolved) : resolved.params + }; +} diff --git a/packages/client/src/scripts/2fa.ts b/packages/client/src/scripts/2fa.ts new file mode 100644 index 0000000000..00363cffa6 --- /dev/null +++ b/packages/client/src/scripts/2fa.ts @@ -0,0 +1,33 @@ +export function byteify(data: string, encoding: 'ascii' | 'base64' | 'hex') { + switch (encoding) { + case 'ascii': + return Uint8Array.from(data, c => c.charCodeAt(0)); + case 'base64': + return Uint8Array.from( + atob( + data + .replace(/-/g, '+') + .replace(/_/g, '/') + ), + c => c.charCodeAt(0) + ); + case 'hex': + return new Uint8Array( + data + .match(/.{1,2}/g) + .map(byte => parseInt(byte, 16)) + ); + } +} + +export function hexify(buffer: ArrayBuffer) { + return Array.from(new Uint8Array(buffer)) + .reduce( + (str, byte) => str + byte.toString(16).padStart(2, '0'), + '' + ); +} + +export function stringify(buffer: ArrayBuffer) { + return String.fromCharCode(... new Uint8Array(buffer)); +} diff --git a/packages/client/src/scripts/aiscript/api.ts b/packages/client/src/scripts/aiscript/api.ts new file mode 100644 index 0000000000..20c15d809e --- /dev/null +++ b/packages/client/src/scripts/aiscript/api.ts @@ -0,0 +1,44 @@ +import { utils, values } from '@syuilo/aiscript'; +import * as os from '@/os'; +import { $i } from '@/account'; + +export function createAiScriptEnv(opts) { + let apiRequests = 0; + return { + USER_ID: $i ? values.STR($i.id) : values.NULL, + USER_NAME: $i ? values.STR($i.name) : values.NULL, + USER_USERNAME: $i ? values.STR($i.username) : values.NULL, + 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { + await os.dialog({ + type: type ? type.value : 'info', + title: title.value, + text: text.value, + }); + }), + 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { + const confirm = await os.dialog({ + type: type ? type.value : 'question', + showCancelButton: true, + title: title.value, + text: text.value, + }); + return confirm.canceled ? values.FALSE : values.TRUE; + }), + 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { + if (token) utils.assertString(token); + apiRequests++; + if (apiRequests > 16) return values.NULL; + const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null)); + return utils.jsToVal(res); + }), + 'Mk:save': values.FN_NATIVE(([key, value]) => { + utils.assertString(key); + localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value))); + return values.NULL; + }), + 'Mk:load': values.FN_NATIVE(([key]) => { + utils.assertString(key); + return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value))); + }), + }; +} diff --git a/packages/client/src/scripts/array.ts b/packages/client/src/scripts/array.ts new file mode 100644 index 0000000000..d63f0475d0 --- /dev/null +++ b/packages/client/src/scripts/array.ts @@ -0,0 +1,138 @@ +import { EndoRelation, Predicate } from './relation'; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf<T>(f: Predicate<T>, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count<T>(a: T, xs: T[]): number { + return countIf(x => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat<T>(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ +export function intersperse<T>(sep: T, xs: T[]): T[] { + return concat(xs.map(x => [sep, x])).slice(1); +} + +/** + * Returns the array of elements that is not equal to the element + */ +export function erase<T>(a: T, xs: T[]): T[] { + return xs.filter(x => x !== a); +} + +/** + * Finds the array of all elements in the first array not contained in the second array. + * The order of result values are determined by the first array. + */ +export function difference<T>(xs: T[], ys: T[]): T[] { + return xs.filter(x => !ys.includes(x)); +} + +/** + * Remove all but the first element from every group of equivalent elements + */ +export function unique<T>(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] { + const groups = [] as T[][]; + for (const x of xs) { + if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { + groups[groups.length - 1].push(x); + } else { + groups.push([x]); + } + } + return groups; +} + +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ +export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] { + return groupBy((a, b) => f(a) === f(b), xs); +} + +export function groupByX<T>(collections: T[], keySelector: (x: T) => string) { + return collections.reduce((obj: Record<string, T[]>, item: T) => { + const key = keySelector(item); + if (!obj.hasOwnProperty(key)) { + obj[key] = []; + } + + obj[key].push(item); + + return obj; + }, {}); +} + +/** + * Compare two arrays by lexicographical order + */ +export function lessThan(xs: number[], ys: number[]): boolean { + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + if (xs[i] < ys[i]) return true; + if (xs[i] > ys[i]) return false; + } + return xs.length < ys.length; +} + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] { + const ys = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} + +export function toArray<T>(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle<T>(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} diff --git a/packages/client/src/scripts/autocomplete.ts b/packages/client/src/scripts/autocomplete.ts new file mode 100644 index 0000000000..f2d5806484 --- /dev/null +++ b/packages/client/src/scripts/autocomplete.ts @@ -0,0 +1,276 @@ +import { Ref, ref } from 'vue'; +import * as getCaretCoordinates from 'textarea-caret'; +import { toASCII } from 'punycode/'; +import { popup } from '@/os'; + +export class Autocomplete { + private suggestion: { + x: Ref<number>; + y: Ref<number>; + q: Ref<string | null>; + close: Function; + } | null; + private textarea: any; + private vm: any; + private currentType: string; + private opts: { + model: string; + }; + private opening: boolean; + + private get text(): string { + return this.vm[this.opts.model]; + } + + private set text(text: string) { + this.vm[this.opts.model] = text; + } + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea, vm, opts) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + this.vm = vm; + this.opts = opts; + this.opening = false; + + this.attach(); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + const caretPos = this.textarea.selectionStart; + const text = this.text.substr(0, caretPos).split('\n').pop()!; + + const mentionIndex = text.lastIndexOf('@'); + const hashtagIndex = text.lastIndexOf('#'); + const emojiIndex = text.lastIndexOf(':'); + const mfmTagIndex = text.lastIndexOf('$'); + + const max = Math.max( + mentionIndex, + hashtagIndex, + emojiIndex, + mfmTagIndex); + + if (max == -1) { + this.close(); + return; + } + + const isMention = mentionIndex != -1; + const isHashtag = hashtagIndex != -1; + const isMfmTag = mfmTagIndex != -1; + const isEmoji = emojiIndex != -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); + + let opened = false; + + if (isMention) { + const username = text.substr(mentionIndex + 1); + if (username != '' && username.match(/^[a-zA-Z0-9_]+$/)) { + this.open('user', username); + opened = true; + } else if (username === '') { + this.open('user', null); + opened = true; + } + } + + if (isHashtag && !opened) { + const hashtag = text.substr(hashtagIndex + 1); + if (!hashtag.includes(' ')) { + this.open('hashtag', hashtag); + opened = true; + } + } + + if (isEmoji && !opened) { + const emoji = text.substr(emojiIndex + 1); + if (!emoji.includes(' ')) { + this.open('emoji', emoji); + opened = true; + } + } + + if (isMfmTag && !opened) { + const mfmTag = text.substr(mfmTagIndex + 1); + if (!mfmTag.includes(' ')) { + this.open('mfmTag', mfmTag.replace('[', '')); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private async open(type: string, q: string | null) { + if (type != this.currentType) { + this.close(); + } + if (this.opening) return; + this.opening = true; + this.currentType = type; + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + caretPosition.top - this.textarea.scrollTop; + //#endregion + + if (this.suggestion) { + this.suggestion.x.value = x; + this.suggestion.y.value = y; + this.suggestion.q.value = q; + + this.opening = false; + } else { + const _x = ref(x); + const _y = ref(y); + const _q = ref(q); + + const { dispose } = await popup(import('@/components/autocomplete.vue'), { + textarea: this.textarea, + close: this.close, + type: type, + q: _q, + x: _x, + y: _y, + }, { + done: (res) => { + this.complete(res); + } + }); + + this.suggestion = { + q: _q, + x: _x, + y: _y, + close: () => dispose(), + }; + + this.opening = false; + } + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.close(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete({ type, value }) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type == 'user') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(caret); + + const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`; + + // 挿入 + this.text = `${trimmedBefore}@${acct} ${after}`; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (acct.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type == 'hashtag') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('#')); + const after = source.substr(caret); + + // 挿入 + this.text = `${trimmedBefore}#${value} ${after}`; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type == 'emoji') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + value.length; + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type == 'mfmTag') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('$')); + const after = source.substr(caret); + + // 挿入 + this.text = `${trimmedBefore}$[${value} ]${after}`; + + // キャレットを戻す + this.vm.$nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 3); + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts new file mode 100644 index 0000000000..3b1fa75b1e --- /dev/null +++ b/packages/client/src/scripts/check-word-mute.ts @@ -0,0 +1,26 @@ +export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + const words = mutedWords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (words.length > 0) { + if (note.text == null) return false; + + const matched = words.some(and => + and.every(keyword => { + const regexp = keyword.match(/^\/(.+)\/(.*)$/); + if (regexp) { + return new RegExp(regexp[1], regexp[2]).test(note.text!); + } + return note.text!.includes(keyword); + })); + + if (matched) return true; + } + + return false; +} diff --git a/packages/client/src/scripts/collect-page-vars.ts b/packages/client/src/scripts/collect-page-vars.ts new file mode 100644 index 0000000000..a4096fb2c2 --- /dev/null +++ b/packages/client/src/scripts/collect-page-vars.ts @@ -0,0 +1,48 @@ +export function collectPageVars(content) { + const pageVars = []; + const collect = (xs: any[]) => { + for (const x of xs) { + if (x.type === 'textInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '' + }); + } else if (x.type === 'textareaInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '' + }); + } else if (x.type === 'numberInput') { + pageVars.push({ + name: x.name, + type: 'number', + value: x.default || 0 + }); + } else if (x.type === 'switch') { + pageVars.push({ + name: x.name, + type: 'boolean', + value: x.default || false + }); + } else if (x.type === 'counter') { + pageVars.push({ + name: x.name, + type: 'number', + value: 0 + }); + } else if (x.type === 'radioButton') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '' + }); + } else if (x.children) { + collect(x.children); + } + } + }; + collect(content); + return pageVars; +} diff --git a/packages/client/src/scripts/contains.ts b/packages/client/src/scripts/contains.ts new file mode 100644 index 0000000000..770bda63bb --- /dev/null +++ b/packages/client/src/scripts/contains.ts @@ -0,0 +1,9 @@ +export default (parent, child, checkSame = true) => { + if (checkSame && parent === child) return true; + let node = child.parentNode; + while (node) { + if (node == parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/packages/client/src/scripts/copy-to-clipboard.ts b/packages/client/src/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000000..ab13cab970 --- /dev/null +++ b/packages/client/src/scripts/copy-to-clipboard.ts @@ -0,0 +1,33 @@ +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export default val => { + // 空div 生成 + const tmp = document.createElement('div'); + // 選択用のタグ生成 + const pre = document.createElement('pre'); + + // 親要素のCSSで user-select: none だとコピーできないので書き換える + pre.style.webkitUserSelect = 'auto'; + pre.style.userSelect = 'auto'; + + tmp.appendChild(pre).textContent = val; + + // 要素を画面外へ + const s = tmp.style; + s.position = 'fixed'; + s.right = '200%'; + + // body に追加 + document.body.appendChild(tmp); + // 要素を選択 + document.getSelection().selectAllChildren(tmp); + + // クリップボードにコピー + const result = document.execCommand('copy'); + + // 要素削除 + document.body.removeChild(tmp); + + return result; +}; diff --git a/packages/client/src/scripts/emojilist.ts b/packages/client/src/scripts/emojilist.ts new file mode 100644 index 0000000000..de7591f5a0 --- /dev/null +++ b/packages/client/src/scripts/emojilist.ts @@ -0,0 +1,7 @@ +// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb +export const emojilist = require('../emojilist.json') as { + name: string; + keywords: string[]; + char: string; + category: 'people' | 'animals_and_nature' | 'food_and_drink' | 'activity' | 'travel_and_places' | 'objects' | 'symbols' | 'flags'; +}[]; diff --git a/packages/client/src/scripts/extract-avg-color-from-blurhash.ts b/packages/client/src/scripts/extract-avg-color-from-blurhash.ts new file mode 100644 index 0000000000..123ab7a06d --- /dev/null +++ b/packages/client/src/scripts/extract-avg-color-from-blurhash.ts @@ -0,0 +1,9 @@ +export function extractAvgColorFromBlurhash(hash: string) { + return typeof hash == 'string' + ? '#' + [...hash.slice(2, 6)] + .map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x)) + .reduce((a, c) => a * 83 + c, 0) + .toString(16) + .padStart(6, '0') + : undefined; +} diff --git a/packages/client/src/scripts/extract-mentions.ts b/packages/client/src/scripts/extract-mentions.ts new file mode 100644 index 0000000000..cc19b161a8 --- /dev/null +++ b/packages/client/src/scripts/extract-mentions.ts @@ -0,0 +1,11 @@ +// test is located in test/extract-mentions + +import * as mfm from 'mfm-js'; + +export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { + // TODO: 重複を削除 + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentions = mentionNodes.map(x => x.props); + + return mentions; +} diff --git a/packages/client/src/scripts/extract-url-from-mfm.ts b/packages/client/src/scripts/extract-url-from-mfm.ts new file mode 100644 index 0000000000..34e3eb6c19 --- /dev/null +++ b/packages/client/src/scripts/extract-url-from-mfm.ts @@ -0,0 +1,19 @@ +import * as mfm from 'mfm-js'; +import { unique } from '@/scripts/array'; + +// unique without hash +// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] +const removeHash = (x: string) => x.replace(/#[^#]*$/, ''); + +export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] { + const urlNodes = mfm.extract(nodes, (node) => { + return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent)); + }); + const urls: string[] = unique(urlNodes.map(x => x.props.url)); + + return urls.reduce((array, url) => { + const urlWithoutHash = removeHash(url); + if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url); + return array; + }, [] as string[]); +} diff --git a/packages/client/src/scripts/focus.ts b/packages/client/src/scripts/focus.ts new file mode 100644 index 0000000000..0894877820 --- /dev/null +++ b/packages/client/src/scripts/focus.ts @@ -0,0 +1,27 @@ +export function focusPrev(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.previousElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll + }); + } else { + focusPrev(el.previousElementSibling, true); + } + } +} + +export function focusNext(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.nextElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll + }); + } else { + focusPrev(el.nextElementSibling, true); + } + } +} diff --git a/packages/client/src/scripts/form.ts b/packages/client/src/scripts/form.ts new file mode 100644 index 0000000000..7bf6cec452 --- /dev/null +++ b/packages/client/src/scripts/form.ts @@ -0,0 +1,31 @@ +export type FormItem = { + label?: string; + type: 'string'; + default: string | null; + hidden?: boolean; + multiline?: boolean; +} | { + label?: string; + type: 'number'; + default: number | null; + hidden?: boolean; + step?: number; +} | { + label?: string; + type: 'boolean'; + default: boolean | null; + hidden?: boolean; +} | { + label?: string; + type: 'enum'; + default: string | null; + hidden?: boolean; + enum: string[]; +} | { + label?: string; + type: 'array'; + default: unknown[] | null; + hidden?: boolean; +}; + +export type Form = Record<string, FormItem>; diff --git a/packages/client/src/scripts/format-time-string.ts b/packages/client/src/scripts/format-time-string.ts new file mode 100644 index 0000000000..bfb2c397ae --- /dev/null +++ b/packages/client/src/scripts/format-time-string.ts @@ -0,0 +1,50 @@ +const defaultLocaleStringFormats: {[index: string]: string} = { + 'weekday': 'narrow', + 'era': 'narrow', + 'year': 'numeric', + 'month': 'numeric', + 'day': 'numeric', + 'hour': 'numeric', + 'minute': 'numeric', + 'second': 'numeric', + 'timeZoneName': 'short' +}; + +function formatLocaleString(date: Date, format: string): string { + return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => { + if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) { + return date.toLocaleString(window.navigator.language, {[kind]: option ? option : defaultLocaleStringFormats[kind]}); + } else { + return match; + } + }); +} + +export function formatDateTimeString(date: Date, format: string): string { + return format + .replace(/yyyy/g, date.getFullYear().toString()) + .replace(/yy/g, date.getFullYear().toString().slice(-2)) + .replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long'})) + .replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short'})) + .replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2)) + .replace(/M/g, (date.getMonth() + 1).toString()) + .replace(/dd/g, (`0${date.getDate()}`).slice(-2)) + .replace(/d/g, date.getDate().toString()) + .replace(/HH/g, (`0${date.getHours()}`).slice(-2)) + .replace(/H/g, date.getHours().toString()) + .replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2)) + .replace(/h/g, ((date.getHours() % 12) || 12).toString()) + .replace(/mm/g, (`0${date.getMinutes()}`).slice(-2)) + .replace(/m/g, date.getMinutes().toString()) + .replace(/ss/g, (`0${date.getSeconds()}`).slice(-2)) + .replace(/s/g, date.getSeconds().toString()) + .replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM'); +} + +export function formatTimeString(date: Date, format: string): string { + return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => { + if (localeformat) return formatLocaleString(date, localeformat); + if (datetimeformat) return formatDateTimeString(date, datetimeformat); + return match; + }); +} diff --git a/packages/client/src/scripts/games/reversi/core.ts b/packages/client/src/scripts/games/reversi/core.ts new file mode 100644 index 0000000000..0cb8922e19 --- /dev/null +++ b/packages/client/src/scripts/games/reversi/core.ts @@ -0,0 +1,263 @@ +import { count, concat } from '@/scripts/array'; + +// MISSKEY REVERSI ENGINE + +/** + * true ... 黒 + * false ... 白 + */ +export type Color = boolean; +const BLACK = true; +const WHITE = false; + +export type MapPixel = 'null' | 'empty'; + +export type Options = { + isLlotheo: boolean; + canPutEverywhere: boolean; + loopedBoard: boolean; +}; + +export type Undo = { + /** + * 色 + */ + color: Color; + + /** + * どこに打ったか + */ + pos: number; + + /** + * 反転した石の位置の配列 + */ + effects: number[]; + + /** + * ターン + */ + turn: Color | null; +}; + +/** + * リバーシエンジン + */ +export default class Reversi { + public map: MapPixel[]; + public mapWidth: number; + public mapHeight: number; + public board: (Color | null | undefined)[]; + public turn: Color | null = BLACK; + public opts: Options; + + public prevPos = -1; + public prevColor: Color | null = null; + + private logs: Undo[] = []; + + /** + * ゲームを初期化します + */ + constructor(map: string[], opts: Options) { + //#region binds + this.put = this.put.bind(this); + //#endregion + + //#region Options + this.opts = opts; + if (this.opts.isLlotheo == null) this.opts.isLlotheo = false; + if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false; + if (this.opts.loopedBoard == null) this.opts.loopedBoard = false; + //#endregion + + //#region Parse map data + this.mapWidth = map[0].length; + this.mapHeight = map.length; + const mapData = map.join(''); + + this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined); + + this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null'); + //#endregion + + // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある + if (!this.canPutSomewhere(BLACK)) + this.turn = this.canPutSomewhere(WHITE) ? WHITE : null; + } + + /** + * 黒石の数 + */ + public get blackCount() { + return count(BLACK, this.board); + } + + /** + * 白石の数 + */ + public get whiteCount() { + return count(WHITE, this.board); + } + + public transformPosToXy(pos: number): number[] { + const x = pos % this.mapWidth; + const y = Math.floor(pos / this.mapWidth); + return [x, y]; + } + + public transformXyToPos(x: number, y: number): number { + return x + (y * this.mapWidth); + } + + /** + * 指定のマスに石を打ちます + * @param color 石の色 + * @param pos 位置 + */ + public put(color: Color, pos: number) { + this.prevPos = pos; + this.prevColor = color; + + this.board[pos] = color; + + // 反転させられる石を取得 + const effects = this.effects(color, pos); + + // 反転させる + for (const pos of effects) { + this.board[pos] = color; + } + + const turn = this.turn; + + this.logs.push({ + color, + pos, + effects, + turn + }); + + this.calcTurn(); + } + + private calcTurn() { + // ターン計算 + this.turn = + this.canPutSomewhere(!this.prevColor) ? !this.prevColor : + this.canPutSomewhere(this.prevColor!) ? this.prevColor : + null; + } + + public undo() { + const undo = this.logs.pop()!; + this.prevColor = undo.color; + this.prevPos = undo.pos; + this.board[undo.pos] = null; + for (const pos of undo.effects) { + const color = this.board[pos]; + this.board[pos] = !color; + } + this.turn = undo.turn; + } + + /** + * 指定した位置のマップデータのマスを取得します + * @param pos 位置 + */ + public mapDataGet(pos: number): MapPixel { + const [x, y] = this.transformPosToXy(pos); + return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos]; + } + + /** + * 打つことができる場所を取得します + */ + public puttablePlaces(color: Color): number[] { + return Array.from(this.board.keys()).filter(i => this.canPut(color, i)); + } + + /** + * 打つことができる場所があるかどうかを取得します + */ + public canPutSomewhere(color: Color): boolean { + return this.puttablePlaces(color).length > 0; + } + + /** + * 指定のマスに石を打つことができるかどうかを取得します + * @param color 自分の色 + * @param pos 位置 + */ + public canPut(color: Color, pos: number): boolean { + return ( + this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない + this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード + this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか + } + + /** + * 指定のマスに石を置いた時の、反転させられる石を取得します + * @param color 自分の色 + * @param initPos 位置 + */ + public effects(color: Color, initPos: number): number[] { + const enemyColor = !color; + + const diffVectors: [number, number][] = [ + [ 0, -1], // 上 + [ +1, -1], // 右上 + [ +1, 0], // 右 + [ +1, +1], // 右下 + [ 0, +1], // 下 + [ -1, +1], // 左下 + [ -1, 0], // 左 + [ -1, -1] // 左上 + ]; + + const effectsInLine = ([dx, dy]: [number, number]): number[] => { + const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy]; + + const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列 + let [x, y] = this.transformPosToXy(initPos); + while (true) { + [x, y] = nextPos(x, y); + + // 座標が指し示す位置がボード外に出たとき + if (this.opts.loopedBoard && this.transformXyToPos( + (x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth), + (y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos) + // 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ) + return found; + else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight) + return []; // 挟めないことが確定 (盤面外に到達) + + const pos = this.transformXyToPos(x, y); + if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達) + const stone = this.board[pos]; + if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達) + if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見) + if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見) + } + }; + + return concat(diffVectors.map(effectsInLine)); + } + + /** + * ゲームが終了したか否か + */ + public get isEnded(): boolean { + return this.turn === null; + } + + /** + * ゲームの勝者 (null = 引き分け) + */ + public get winner(): Color | null { + return this.isEnded ? + this.blackCount == this.whiteCount ? null : + this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK : + undefined as never; + } +} diff --git a/packages/client/src/scripts/games/reversi/maps.ts b/packages/client/src/scripts/games/reversi/maps.ts new file mode 100644 index 0000000000..dc0d1bf9d0 --- /dev/null +++ b/packages/client/src/scripts/games/reversi/maps.ts @@ -0,0 +1,896 @@ +/** + * 組み込みマップ定義 + * + * データ値: + * (スペース) ... マス無し + * - ... マス + * b ... 初期配置される黒石 + * w ... 初期配置される白石 + */ + +export type Map = { + name?: string; + category?: string; + author?: string; + data: string[]; +}; + +export const fourfour: Map = { + name: '4x4', + category: '4x4', + data: [ + '----', + '-wb-', + '-bw-', + '----' + ] +}; + +export const sixsix: Map = { + name: '6x6', + category: '6x6', + data: [ + '------', + '------', + '--wb--', + '--bw--', + '------', + '------' + ] +}; + +export const roundedSixsix: Map = { + name: '6x6 rounded', + category: '6x6', + author: 'syuilo', + data: [ + ' ---- ', + '------', + '--wb--', + '--bw--', + '------', + ' ---- ' + ] +}; + +export const roundedSixsix2: Map = { + name: '6x6 rounded 2', + category: '6x6', + author: 'syuilo', + data: [ + ' -- ', + ' ---- ', + '--wb--', + '--bw--', + ' ---- ', + ' -- ' + ] +}; + +export const eighteight: Map = { + name: '8x8', + category: '8x8', + data: [ + '--------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const eighteightH1: Map = { + name: '8x8 handicap 1', + category: '8x8', + data: [ + 'b-------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const eighteightH2: Map = { + name: '8x8 handicap 2', + category: '8x8', + data: [ + 'b-------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '-------b' + ] +}; + +export const eighteightH3: Map = { + name: '8x8 handicap 3', + category: '8x8', + data: [ + 'b------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '-------b' + ] +}; + +export const eighteightH4: Map = { + name: '8x8 handicap 4', + category: '8x8', + data: [ + 'b------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + 'b------b' + ] +}; + +export const eighteightH28: Map = { + name: '8x8 handicap 28', + category: '8x8', + data: [ + 'bbbbbbbb', + 'b------b', + 'b------b', + 'b--wb--b', + 'b--bw--b', + 'b------b', + 'b------b', + 'bbbbbbbb' + ] +}; + +export const roundedEighteight: Map = { + name: '8x8 rounded', + category: '8x8', + author: 'syuilo', + data: [ + ' ------ ', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + ' ------ ' + ] +}; + +export const roundedEighteight2: Map = { + name: '8x8 rounded 2', + category: '8x8', + author: 'syuilo', + data: [ + ' ---- ', + ' ------ ', + '--------', + '---wb---', + '---bw---', + '--------', + ' ------ ', + ' ---- ' + ] +}; + +export const roundedEighteight3: Map = { + name: '8x8 rounded 3', + category: '8x8', + author: 'syuilo', + data: [ + ' -- ', + ' ---- ', + ' ------ ', + '---wb---', + '---bw---', + ' ------ ', + ' ---- ', + ' -- ' + ] +}; + +export const eighteightWithNotch: Map = { + name: '8x8 with notch', + category: '8x8', + author: 'syuilo', + data: [ + '--- ---', + '--------', + '--------', + ' --wb-- ', + ' --bw-- ', + '--------', + '--------', + '--- ---' + ] +}; + +export const eighteightWithSomeHoles: Map = { + name: '8x8 with some holes', + category: '8x8', + author: 'syuilo', + data: [ + '--- ----', + '----- --', + '-- -----', + '---wb---', + '---bw- -', + ' -------', + '--- ----', + '--------' + ] +}; + +export const circle: Map = { + name: 'Circle', + category: '8x8', + author: 'syuilo', + data: [ + ' -- ', + ' ------ ', + ' ------ ', + '---wb---', + '---bw---', + ' ------ ', + ' ------ ', + ' -- ' + ] +}; + +export const smile: Map = { + name: 'Smile', + category: '8x8', + author: 'syuilo', + data: [ + ' ------ ', + '--------', + '-- -- --', + '---wb---', + '-- bw --', + '--- ---', + '--------', + ' ------ ' + ] +}; + +export const window: Map = { + name: 'Window', + category: '8x8', + author: 'syuilo', + data: [ + '--------', + '- -- -', + '- -- -', + '---wb---', + '---bw---', + '- -- -', + '- -- -', + '--------' + ] +}; + +export const reserved: Map = { + name: 'Reserved', + category: '8x8', + author: 'Aya', + data: [ + 'w------b', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + 'b------w' + ] +}; + +export const x: Map = { + name: 'X', + category: '8x8', + author: 'Aya', + data: [ + 'w------b', + '-w----b-', + '--w--b--', + '---wb---', + '---bw---', + '--b--w--', + '-b----w-', + 'b------w' + ] +}; + +export const parallel: Map = { + name: 'Parallel', + category: '8x8', + author: 'Aya', + data: [ + '--------', + '--------', + '--------', + '---bb---', + '---ww---', + '--------', + '--------', + '--------' + ] +}; + +export const lackOfBlack: Map = { + name: 'Lack of Black', + category: '8x8', + data: [ + '--------', + '--------', + '--------', + '---w----', + '---bw---', + '--------', + '--------', + '--------' + ] +}; + +export const squareParty: Map = { + name: 'Square Party', + category: '8x8', + author: 'syuilo', + data: [ + '--------', + '-wwwbbb-', + '-w-wb-b-', + '-wwwbbb-', + '-bbbwww-', + '-b-bw-w-', + '-bbbwww-', + '--------' + ] +}; + +export const minesweeper: Map = { + name: 'Minesweeper', + category: '8x8', + author: 'syuilo', + data: [ + 'b-b--w-w', + '-w-wb-b-', + 'w-b--w-b', + '-b-wb-w-', + '-w-bw-b-', + 'b-w--b-w', + '-b-bw-w-', + 'w-w--b-b' + ] +}; + +export const tenthtenth: Map = { + name: '10x10', + category: '10x10', + data: [ + '----------', + '----------', + '----------', + '----------', + '----wb----', + '----bw----', + '----------', + '----------', + '----------', + '----------' + ] +}; + +export const hole: Map = { + name: 'The Hole', + category: '10x10', + author: 'syuilo', + data: [ + '----------', + '----------', + '--wb--wb--', + '--bw--bw--', + '---- ----', + '---- ----', + '--wb--wb--', + '--bw--bw--', + '----------', + '----------' + ] +}; + +export const grid: Map = { + name: 'Grid', + category: '10x10', + author: 'syuilo', + data: [ + '----------', + '- - -- - -', + '----------', + '- - -- - -', + '----wb----', + '----bw----', + '- - -- - -', + '----------', + '- - -- - -', + '----------' + ] +}; + +export const cross: Map = { + name: 'Cross', + category: '10x10', + author: 'Aya', + data: [ + ' ---- ', + ' ---- ', + ' ---- ', + '----------', + '----wb----', + '----bw----', + '----------', + ' ---- ', + ' ---- ', + ' ---- ' + ] +}; + +export const charX: Map = { + name: 'Char X', + category: '10x10', + author: 'syuilo', + data: [ + '--- ---', + '---- ----', + '----------', + ' -------- ', + ' --wb-- ', + ' --bw-- ', + ' -------- ', + '----------', + '---- ----', + '--- ---' + ] +}; + +export const charY: Map = { + name: 'Char Y', + category: '10x10', + author: 'syuilo', + data: [ + '--- ---', + '---- ----', + '----------', + ' -------- ', + ' --wb-- ', + ' --bw-- ', + ' ------ ', + ' ------ ', + ' ------ ', + ' ------ ' + ] +}; + +export const walls: Map = { + name: 'Walls', + category: '10x10', + author: 'Aya', + data: [ + ' bbbbbbbb ', + 'w--------w', + 'w--------w', + 'w--------w', + 'w---wb---w', + 'w---bw---w', + 'w--------w', + 'w--------w', + 'w--------w', + ' bbbbbbbb ' + ] +}; + +export const cpu: Map = { + name: 'CPU', + category: '10x10', + author: 'syuilo', + data: [ + ' b b b b ', + 'w--------w', + ' -------- ', + 'w--------w', + ' ---wb--- ', + ' ---bw--- ', + 'w--------w', + ' -------- ', + 'w--------w', + ' b b b b ' + ] +}; + +export const checker: Map = { + name: 'Checker', + category: '10x10', + author: 'Aya', + data: [ + '----------', + '----------', + '----------', + '---wbwb---', + '---bwbw---', + '---wbwb---', + '---bwbw---', + '----------', + '----------', + '----------' + ] +}; + +export const japaneseCurry: Map = { + name: 'Japanese curry', + category: '10x10', + author: 'syuilo', + data: [ + 'w-b-b-b-b-', + '-w-b-b-b-b', + 'w-w-b-b-b-', + '-w-w-b-b-b', + 'w-w-wwb-b-', + '-w-wbb-b-b', + 'w-w-w-b-b-', + '-w-w-w-b-b', + 'w-w-w-w-b-', + '-w-w-w-w-b' + ] +}; + +export const mosaic: Map = { + name: 'Mosaic', + category: '10x10', + author: 'syuilo', + data: [ + '- - - - - ', + ' - - - - -', + '- - - - - ', + ' - w w - -', + '- - b b - ', + ' - w w - -', + '- - b b - ', + ' - - - - -', + '- - - - - ', + ' - - - - -', + ] +}; + +export const arena: Map = { + name: 'Arena', + category: '10x10', + author: 'syuilo', + data: [ + '- - -- - -', + ' - - - - ', + '- ------ -', + ' -------- ', + '- --wb-- -', + '- --bw-- -', + ' -------- ', + '- ------ -', + ' - - - - ', + '- - -- - -' + ] +}; + +export const reactor: Map = { + name: 'Reactor', + category: '10x10', + author: 'syuilo', + data: [ + '-w------b-', + 'b- - - -w', + '- --wb-- -', + '---b w---', + '- b wb w -', + '- w bw b -', + '---w b---', + '- --bw-- -', + 'w- - - -b', + '-b------w-' + ] +}; + +export const sixeight: Map = { + name: '6x8', + category: 'Special', + data: [ + '------', + '------', + '------', + '--wb--', + '--bw--', + '------', + '------', + '------' + ] +}; + +export const spark: Map = { + name: 'Spark', + category: 'Special', + author: 'syuilo', + data: [ + ' - - ', + '----------', + ' -------- ', + ' -------- ', + ' ---wb--- ', + ' ---bw--- ', + ' -------- ', + ' -------- ', + '----------', + ' - - ' + ] +}; + +export const islands: Map = { + name: 'Islands', + category: 'Special', + author: 'syuilo', + data: [ + '-------- ', + '---wb--- ', + '---bw--- ', + '-------- ', + ' - - ', + ' - - ', + ' --------', + ' --------', + ' --------', + ' --------' + ] +}; + +export const galaxy: Map = { + name: 'Galaxy', + category: 'Special', + author: 'syuilo', + data: [ + ' ------ ', + ' --www--- ', + ' ------w--- ', + '---bbb--w---', + '--b---b-w-b-', + '-b--wwb-w-b-', + '-b-w-bww--b-', + '-b-w-b---b--', + '---w--bbb---', + ' ---w------ ', + ' ---www-- ', + ' ------ ' + ] +}; + +export const triangle: Map = { + name: 'Triangle', + category: 'Special', + author: 'syuilo', + data: [ + ' -- ', + ' -- ', + ' ---- ', + ' ---- ', + ' --wb-- ', + ' --bw-- ', + ' -------- ', + ' -------- ', + '----------', + '----------' + ] +}; + +export const iphonex: Map = { + name: 'iPhone X', + category: 'Special', + author: 'syuilo', + data: [ + ' -- -- ', + '--------', + '--------', + '--------', + '--------', + '---wb---', + '---bw---', + '--------', + '--------', + '--------', + '--------', + ' ------ ' + ] +}; + +export const dealWithIt: Map = { + name: 'Deal with it!', + category: 'Special', + author: 'syuilo', + data: [ + '------------', + '--w-b-------', + ' --b-w------', + ' --w-b---- ', + ' ------- ' + ] +}; + +export const experiment: Map = { + name: 'Let\'s experiment', + category: 'Special', + author: 'syuilo', + data: [ + ' ------------ ', + '------wb------', + '------bw------', + '--------------', + ' - - ', + '------ ------', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'bbbbbb wwwwww', + 'wwwwww bbbbbb' + ] +}; + +export const bigBoard: Map = { + name: 'Big board', + category: 'Special', + data: [ + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '-------wb-------', + '-------bw-------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------', + '----------------' + ] +}; + +export const twoBoard: Map = { + name: 'Two board', + category: 'Special', + author: 'Aya', + data: [ + '-------- --------', + '-------- --------', + '-------- --------', + '---wb--- ---wb---', + '---bw--- ---bw---', + '-------- --------', + '-------- --------', + '-------- --------' + ] +}; + +export const test1: Map = { + name: 'Test1', + category: 'Test', + data: [ + '--------', + '---wb---', + '---bw---', + '--------' + ] +}; + +export const test2: Map = { + name: 'Test2', + category: 'Test', + data: [ + '------', + '------', + '-b--w-', + '-w--b-', + '-w--b-' + ] +}; + +export const test3: Map = { + name: 'Test3', + category: 'Test', + data: [ + '-w-', + '--w', + 'w--', + '-w-', + '--w', + 'w--', + '-w-', + '--w', + 'w--', + '-w-', + '---', + 'b--', + ] +}; + +export const test4: Map = { + name: 'Test4', + category: 'Test', + data: [ + '-w--b-', + '-w--b-', + '------', + '-w--b-', + '-w--b-' + ] +}; + +// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)A1に打ってしまう +export const test6: Map = { + name: 'Test6', + category: 'Test', + data: [ + '--wwwww-', + 'wwwwwwww', + 'wbbbwbwb', + 'wbbbbwbb', + 'wbwbbwbb', + 'wwbwbbbb', + '--wbbbbb', + '-wwwww--', + ] +}; + +// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)G7に打ってしまう +export const test7: Map = { + name: 'Test7', + category: 'Test', + data: [ + 'b--w----', + 'b-wwww--', + 'bwbwwwbb', + 'wbwwwwb-', + 'wwwwwww-', + '-wwbbwwb', + '--wwww--', + '--wwww--', + ] +}; + +// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう +export const test8: Map = { + name: 'Test8', + category: 'Test', + data: [ + '--------', + '-----w--', + 'w--www--', + 'wwwwww--', + 'bbbbwww-', + 'wwwwww--', + '--www---', + '--ww----', + ] +}; diff --git a/packages/client/src/scripts/games/reversi/package.json b/packages/client/src/scripts/games/reversi/package.json new file mode 100644 index 0000000000..a4415ad141 --- /dev/null +++ b/packages/client/src/scripts/games/reversi/package.json @@ -0,0 +1,18 @@ +{ + "name": "misskey-reversi", + "version": "0.0.5", + "description": "Misskey reversi engine", + "keywords": [ + "misskey" + ], + "author": "syuilo <i@syuilo.com>", + "license": "MIT", + "repository": "https://github.com/misskey-dev/misskey.git", + "bugs": "https://github.com/misskey-dev/misskey/issues", + "main": "./built/core.js", + "types": "./built/core.d.ts", + "scripts": { + "build": "tsc" + }, + "dependencies": {} +} diff --git a/packages/client/src/scripts/games/reversi/tsconfig.json b/packages/client/src/scripts/games/reversi/tsconfig.json new file mode 100644 index 0000000000..851fb6b7e4 --- /dev/null +++ b/packages/client/src/scripts/games/reversi/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "experimentalDecorators": true, + "declaration": true, + "sourceMap": false, + "target": "es2017", + "module": "commonjs", + "removeComments": false, + "noLib": false, + "outDir": "./built", + "rootDir": "./" + }, + "compileOnSave": false, + "include": [ + "./core.ts" + ] +} diff --git a/packages/client/src/scripts/gen-search-query.ts b/packages/client/src/scripts/gen-search-query.ts new file mode 100644 index 0000000000..57a06c280c --- /dev/null +++ b/packages/client/src/scripts/gen-search-query.ts @@ -0,0 +1,31 @@ +import * as Acct from 'misskey-js/built/acct'; +import { host as localHost } from '@/config'; + +export async function genSearchQuery(v: any, q: string) { + let host: string; + let userId: string; + if (q.split(' ').some(x => x.startsWith('@'))) { + for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) { + if (at.includes('.')) { + if (at === localHost || at === '.') { + host = null; + } else { + host = at; + } + } else { + const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null); + if (user) { + userId = user.id; + } else { + // todo: show error + } + } + } + + } + return { + query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '), + host: host, + userId: userId + }; +} diff --git a/packages/client/src/scripts/get-account-from-id.ts b/packages/client/src/scripts/get-account-from-id.ts new file mode 100644 index 0000000000..ba3adceecc --- /dev/null +++ b/packages/client/src/scripts/get-account-from-id.ts @@ -0,0 +1,7 @@ +import { get } from '@/scripts/idb-proxy'; + +export async function getAccountFromId(id: string) { + const accounts = await get('accounts') as { token: string; id: string; }[]; + if (!accounts) console.log('Accounts are not recorded'); + return accounts.find(e => e.id === id); +} diff --git a/packages/client/src/scripts/get-md5.ts b/packages/client/src/scripts/get-md5.ts new file mode 100644 index 0000000000..b002d762b1 --- /dev/null +++ b/packages/client/src/scripts/get-md5.ts @@ -0,0 +1,10 @@ +// スクリプトサイズがデカい +//import * as crypto from 'crypto'; + +export default (data: ArrayBuffer) => { + //const buf = new Buffer(data); + //const hash = crypto.createHash('md5'); + //hash.update(buf); + //return hash.digest('hex'); + return ''; +}; diff --git a/packages/client/src/scripts/get-note-summary.ts b/packages/client/src/scripts/get-note-summary.ts new file mode 100644 index 0000000000..bd394279cb --- /dev/null +++ b/packages/client/src/scripts/get-note-summary.ts @@ -0,0 +1,55 @@ +import * as misskey from 'misskey-js'; +import { i18n } from '@/i18n'; + +/** + * 投稿を表す文字列を取得します。 + * @param {*} note (packされた)投稿 + */ +export const getNoteSummary = (note: misskey.entities.Note): string => { + if (note.deletedAt) { + return `(${i18n.locale.deletedNote})`; + } + + if (note.isHidden) { + return `(${i18n.locale.invisibleNote})`; + } + + let summary = ''; + + // 本文 + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ''; + } + + // ファイルが添付されているとき + if ((note.files || []).length != 0) { + summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`; + } + + // 投票が添付されているとき + if (note.poll) { + summary += ` (${i18n.locale.poll})`; + } + + // 返信のとき + if (note.replyId) { + if (note.reply) { + summary += `\n\nRE: ${getNoteSummary(note.reply)}`; + } else { + summary += '\n\nRE: ...'; + } + } + + // Renoteのとき + if (note.renoteId) { + if (note.renote) { + summary += `\n\nRN: ${getNoteSummary(note.renote)}`; + } else { + summary += '\n\nRN: ...'; + } + } + + return summary.trim(); +}; diff --git a/packages/client/src/scripts/get-static-image-url.ts b/packages/client/src/scripts/get-static-image-url.ts new file mode 100644 index 0000000000..e9a3e87cc8 --- /dev/null +++ b/packages/client/src/scripts/get-static-image-url.ts @@ -0,0 +1,16 @@ +import { url as instanceUrl } from '@/config'; +import * as url from '@/scripts/url'; + +export function getStaticImageUrl(baseUrl: string): string { + const u = new URL(baseUrl); + if (u.href.startsWith(`${instanceUrl}/proxy/`)) { + // もう既にproxyっぽそうだったらsearchParams付けるだけ + u.searchParams.set('static', '1'); + return u.href; + } + const dummy = `${u.host}${u.pathname}`; // 拡張子がないとキャッシュしてくれないCDNがあるので + return `${instanceUrl}/proxy/${dummy}?${url.query({ + url: u.href, + static: '1' + })}`; +} diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts new file mode 100644 index 0000000000..8d767afa25 --- /dev/null +++ b/packages/client/src/scripts/get-user-menu.ts @@ -0,0 +1,205 @@ +import { i18n } from '@/i18n'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { host } from '@/config'; +import * as Acct from 'misskey-js/built/acct'; +import * as os from '@/os'; +import { userActions } from '@/store'; +import { router } from '@/router'; +import { $i } from '@/account'; + +export function getUserMenu(user) { + const meId = $i ? $i.id : null; + + async function pushList() { + const t = i18n.locale.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await os.api('users/lists/list'); + if (lists.length === 0) { + os.dialog({ + type: 'error', + text: i18n.locale.youHaveNoLists + }); + return; + } + const { canceled, result: listId } = await os.dialog({ + type: null, + title: t, + select: { + items: lists.map(list => ({ + value: list.id, text: list.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + os.apiWithDialog('users/lists/push', { + listId: listId, + userId: user.id + }); + } + + async function inviteGroup() { + const groups = await os.api('users/groups/owned'); + if (groups.length === 0) { + os.dialog({ + type: 'error', + text: i18n.locale.youHaveNoGroups + }); + return; + } + const { canceled, result: groupId } = await os.dialog({ + type: null, + title: i18n.locale.group, + select: { + items: groups.map(group => ({ + value: group.id, text: group.name + })) + }, + showCancelButton: true + }); + if (canceled) return; + os.apiWithDialog('users/groups/invite', { + groupId: groupId, + userId: user.id + }); + } + + async function toggleMute() { + os.apiWithDialog(user.isMuted ? 'mute/delete' : 'mute/create', { + userId: user.id + }).then(() => { + user.isMuted = !user.isMuted; + }); + } + + async function toggleBlock() { + if (!await getConfirmed(user.isBlocking ? i18n.locale.unblockConfirm : i18n.locale.blockConfirm)) return; + + os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', { + userId: user.id + }).then(() => { + user.isBlocking = !user.isBlocking; + }); + } + + async function toggleSilence() { + if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; + + os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { + userId: user.id + }).then(() => { + user.isSilenced = !user.isSilenced; + }); + } + + async function toggleSuspend() { + if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: user.id + }).then(() => { + user.isSuspended = !user.isSuspended; + }); + } + + function reportAbuse() { + os.popup(import('@/components/abuse-report-window.vue'), { + user: user, + }, {}, 'closed'); + } + + async function getConfirmed(text: string): Promise<boolean> { + const confirm = await os.dialog({ + type: 'warning', + showCancelButton: true, + title: 'confirm', + text, + }); + + return !confirm.canceled; + } + + let menu = [{ + icon: 'fas fa-at', + text: i18n.locale.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host || host}`); + } + }, { + icon: 'fas fa-info-circle', + text: i18n.locale.info, + action: () => { + os.pageWindow(`/user-info/${user.id}`); + } + }, { + icon: 'fas fa-envelope', + text: i18n.locale.sendMessage, + action: () => { + os.post({ specified: user }); + } + }, meId != user.id ? { + type: 'link', + icon: 'fas fa-comments', + text: i18n.locale.startMessaging, + to: '/my/messaging/' + Acct.toString(user), + } : undefined, null, { + icon: 'fas fa-list-ul', + text: i18n.locale.addToList, + action: pushList + }, meId != user.id ? { + icon: 'fas fa-users', + text: i18n.locale.inviteToGroup, + action: inviteGroup + } : undefined] as any; + + if ($i && meId != user.id) { + menu = menu.concat([null, { + icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash', + text: user.isMuted ? i18n.locale.unmute : i18n.locale.mute, + action: toggleMute + }, { + icon: 'fas fa-ban', + text: user.isBlocking ? i18n.locale.unblock : i18n.locale.block, + action: toggleBlock + }]); + + menu = menu.concat([null, { + icon: 'fas fa-exclamation-circle', + text: i18n.locale.reportAbuse, + action: reportAbuse + }]); + + if ($i && ($i.isAdmin || $i.isModerator)) { + menu = menu.concat([null, { + icon: 'fas fa-microphone-slash', + text: user.isSilenced ? i18n.locale.unsilence : i18n.locale.silence, + action: toggleSilence + }, { + icon: 'fas fa-snowflake', + text: user.isSuspended ? i18n.locale.unsuspend : i18n.locale.suspend, + action: toggleSuspend + }]); + } + } + + if ($i && meId === user.id) { + menu = menu.concat([null, { + icon: 'fas fa-pencil-alt', + text: i18n.locale.editProfile, + action: () => { + router.push('/settings/profile'); + } + }]); + } + + if (userActions.length > 0) { + menu = menu.concat([null, ...userActions.map(action => ({ + icon: 'fas fa-plug', + text: action.title, + action: () => { + action.handler(user); + } + }))]); + } + + return menu; +} diff --git a/packages/client/src/scripts/hotkey.ts b/packages/client/src/scripts/hotkey.ts new file mode 100644 index 0000000000..2b3f491fd8 --- /dev/null +++ b/packages/client/src/scripts/hotkey.ts @@ -0,0 +1,88 @@ +import keyCode from './keycode'; + +type Keymap = Record<string, Function>; + +type Pattern = { + which: string[]; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +}; + +type Action = { + patterns: Pattern[]; + callback: Function; + allowRepeat: boolean; +}; + +const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => { + const result = { + patterns: [], + callback: callback, + allowRepeat: true + } as Action; + + if (patterns.match(/^\(.*\)$/) !== null) { + result.allowRepeat = false; + patterns = patterns.slice(1, -1); + } + + result.patterns = patterns.split('|').map(part => { + const pattern = { + which: [], + ctrl: false, + alt: false, + shift: false + } as Pattern; + + const keys = part.trim().split('+').map(x => x.trim().toLowerCase()); + for (const key of keys) { + switch (key) { + case 'ctrl': pattern.ctrl = true; break; + case 'alt': pattern.alt = true; break; + case 'shift': pattern.shift = true; break; + default: pattern.which = keyCode(key).map(k => k.toLowerCase()); + } + } + + return pattern; + }); + + return result; +}); + +const ignoreElemens = ['input', 'textarea']; + +function match(e: KeyboardEvent, patterns: Action['patterns']): boolean { + const key = e.code.toLowerCase(); + return patterns.some(pattern => pattern.which.includes(key) && + pattern.ctrl === e.ctrlKey && + pattern.shift === e.shiftKey && + pattern.alt === e.altKey && + !e.metaKey + ); +} + +export const makeHotkey = (keymap: Keymap) => { + const actions = parseKeymap(keymap); + + return (e: KeyboardEvent) => { + if (document.activeElement) { + if (ignoreElemens.some(el => document.activeElement!.matches(el))) return; + if (document.activeElement.attributes['contenteditable']) return; + } + + for (const action of actions) { + const matched = match(e, action.patterns); + + if (matched) { + if (!action.allowRepeat && e.repeat) return; + + e.preventDefault(); + e.stopPropagation(); + action.callback(e); + break; + } + } + }; +}; diff --git a/packages/client/src/scripts/hpml/block.ts b/packages/client/src/scripts/hpml/block.ts new file mode 100644 index 0000000000..804c5c1124 --- /dev/null +++ b/packages/client/src/scripts/hpml/block.ts @@ -0,0 +1,109 @@ +// blocks + +export type BlockBase = { + id: string; + type: string; +}; + +export type TextBlock = BlockBase & { + type: 'text'; + text: string; +}; + +export type SectionBlock = BlockBase & { + type: 'section'; + title: string; + children: (Block | VarBlock)[]; +}; + +export type ImageBlock = BlockBase & { + type: 'image'; + fileId: string | null; +}; + +export type ButtonBlock = BlockBase & { + type: 'button'; + text: any; + primary: boolean; + action: string; + content: string; + event: string; + message: string; + var: string; + fn: string; +}; + +export type IfBlock = BlockBase & { + type: 'if'; + var: string; + children: Block[]; +}; + +export type TextareaBlock = BlockBase & { + type: 'textarea'; + text: string; +}; + +export type PostBlock = BlockBase & { + type: 'post'; + text: string; + attachCanvasImage: boolean; + canvasId: string; +}; + +export type CanvasBlock = BlockBase & { + type: 'canvas'; + name: string; // canvas id + width: number; + height: number; +}; + +export type NoteBlock = BlockBase & { + type: 'note'; + detailed: boolean; + note: string | null; +}; + +export type Block = + TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock; + +// variable blocks + +export type VarBlockBase = BlockBase & { + name: string; +}; + +export type NumberInputVarBlock = VarBlockBase & { + type: 'numberInput'; + text: string; +}; + +export type TextInputVarBlock = VarBlockBase & { + type: 'textInput'; + text: string; +}; + +export type SwitchVarBlock = VarBlockBase & { + type: 'switch'; + text: string; +}; + +export type RadioButtonVarBlock = VarBlockBase & { + type: 'radioButton'; + title: string; + values: string[]; +}; + +export type CounterVarBlock = VarBlockBase & { + type: 'counter'; + text: string; + inc: number; +}; + +export type VarBlock = + NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock; + +const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter']; +export function isVarBlock(block: Block): block is VarBlock { + return varBlock.includes(block.type); +} diff --git a/packages/client/src/scripts/hpml/evaluator.ts b/packages/client/src/scripts/hpml/evaluator.ts new file mode 100644 index 0000000000..20261d333d --- /dev/null +++ b/packages/client/src/scripts/hpml/evaluator.ts @@ -0,0 +1,234 @@ +import autobind from 'autobind-decorator'; +import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.'; +import { version } from '@/config'; +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { createAiScriptEnv } from '../aiscript/api'; +import { collectPageVars } from '../collect-page-vars'; +import { initHpmlLib, initAiLib } from './lib'; +import * as os from '@/os'; +import { markRaw, ref, Ref, unref } from 'vue'; +import { Expr, isLiteralValue, Variable } from './expr'; + +/** + * Hpml evaluator + */ +export class Hpml { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record<keyof typeof envVarsDef, any>; + public aiscript?: AiScript; + public pageVarUpdatedCallback?: values.VFn; + public canvases: Record<string, HTMLCanvasElement> = {}; + public vars: Ref<Record<string, any>> = ref({}); + public page: Record<string, any>; + + private opts: { + randomSeed: string; visitor?: any; url?: string; + enableAiScript: boolean; + }; + + constructor(page: Hpml['page'], opts: Hpml['opts']) { + this.page = page; + this.variables = this.page.variables; + this.pageVars = collectPageVars(this.page.content); + this.opts = opts; + + if (this.opts.enableAiScript) { + this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({ + storageKey: 'pages:' + this.page.id + }), ...initAiLib(this)}, { + in: (q) => { + return new Promise(ok => { + os.dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + })); + + this.aiscript.scope.opts.onUpdated = (name, value) => { + this.eval(); + }; + } + + const date = new Date(); + + this.envVars = { + AI: 'kawaii', + VERSION: version, + URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '', + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + SEED: opts.randomSeed ? opts.randomSeed : '', + YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`, + AISCRIPT_DISABLED: !this.opts.enableAiScript, + NULL: null + }; + + this.eval(); + } + + @autobind + public eval() { + try { + this.vars.value = this.evaluateVars(); + } catch (e) { + //this.onError(e); + } + } + + @autobind + public interpolate(str: string) { + if (str == null) return null; + return str.replace(/{(.+?)}/g, match => { + const v = unref(this.vars)[match.slice(1, -1).trim()]; + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public callAiScript(fn: string) { + try { + if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []); + } catch (e) {} + } + + @autobind + public registerCanvas(id: string, canvas: any) { + this.canvases[id] = canvas; + } + + @autobind + public updatePageVar(name: string, value: any) { + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar !== undefined) { + pageVar.value = value; + if (this.pageVarUpdatedCallback) { + if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]); + } + } else { + throw new HpmlError(`No such page var '${name}'`); + } + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + this.envVars.SEED = seed; + } + + @autobind + private _interpolateScope(str: string, scope: HpmlScope) { + return str.replace(/{(.+?)}/g, match => { + const v = scope.getState(match.slice(1, -1).trim()); + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public evaluateVars(): Record<string, any> { + const values: Record<string, any> = {}; + + for (const [k, v] of Object.entries(this.envVars)) { + values[k] = v; + } + + for (const v of this.pageVars) { + values[v.name] = v.value; + } + + for (const v of this.variables) { + values[v.name] = this.evaluate(v, new HpmlScope([values])); + } + + return values; + } + + @autobind + private evaluate(expr: Expr, scope: HpmlScope): any { + + if (isLiteralValue(expr)) { + if (expr.type === null) { + return null; + } + + if (expr.type === 'number') { + return parseInt((expr.value as any), 10); + } + + if (expr.type === 'text' || expr.type === 'multiLineText') { + return this._interpolateScope(expr.value || '', scope); + } + + if (expr.type === 'textList') { + return this._interpolateScope(expr.value || '', scope).trim().split('\n'); + } + + if (expr.type === 'ref') { + return scope.getState(expr.value); + } + + if (expr.type === 'aiScriptVar') { + if (this.aiscript) { + try { + return utils.valToJs(this.aiscript.scope.get(expr.value)); + } catch (e) { + return null; + } + } else { + return null; + } + } + + // Define user function + if (expr.type == 'fn') { + return { + slots: expr.value.slots.map(x => x.name), + exec: (slotArg: Record<string, any>) => { + return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id)); + } + } as Fn; + } + return; + } + + // Call user function + if (expr.type.startsWith('fn:')) { + const fnName = expr.type.split(':')[1]; + const fn = scope.getState(fnName); + const args = {} as Record<string, any>; + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + args[name] = this.evaluate(expr.args[i], scope); + } + return fn.exec(args); + } + + if (expr.args === undefined) return null; + + const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor); + + // Call function + const fnName = expr.type; + const fn = (funcs as any)[fnName]; + if (fn == null) { + throw new HpmlError(`No such function '${fnName}'`); + } else { + return fn(...expr.args.map(x => this.evaluate(x, scope))); + } + } +} diff --git a/packages/client/src/scripts/hpml/expr.ts b/packages/client/src/scripts/hpml/expr.ts new file mode 100644 index 0000000000..00e3ed118b --- /dev/null +++ b/packages/client/src/scripts/hpml/expr.ts @@ -0,0 +1,79 @@ +import { literalDefs, Type } from '.'; + +export type ExprBase = { + id: string; +}; + +// value + +export type EmptyValue = ExprBase & { + type: null; + value: null; +}; + +export type TextValue = ExprBase & { + type: 'text'; + value: string; +}; + +export type MultiLineTextValue = ExprBase & { + type: 'multiLineText'; + value: string; +}; + +export type TextListValue = ExprBase & { + type: 'textList'; + value: string; +}; + +export type NumberValue = ExprBase & { + type: 'number'; + value: number; +}; + +export type RefValue = ExprBase & { + type: 'ref'; + value: string; // value is variable name +}; + +export type AiScriptRefValue = ExprBase & { + type: 'aiScriptVar'; + value: string; // value is variable name +}; + +export type UserFnValue = ExprBase & { + type: 'fn'; + value: UserFnInnerValue; +}; +type UserFnInnerValue = { + slots: { + name: string; + type: Type; + }[]; + expression: Expr; +}; + +export type Value = + EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue; + +export function isLiteralValue(expr: Expr): expr is Value { + if (expr.type == null) return true; + if (literalDefs[expr.type]) return true; + return false; +} + +// call function + +export type CallFn = ExprBase & { // "fn:hoge" or string + type: string; + args: Expr[]; + value: null; +}; + +// variable +export type Variable = (Value | CallFn) & { + name: string; +}; + +// expression +export type Expr = Variable | Value | CallFn; diff --git a/packages/client/src/scripts/hpml/index.ts b/packages/client/src/scripts/hpml/index.ts new file mode 100644 index 0000000000..ac81eac2d9 --- /dev/null +++ b/packages/client/src/scripts/hpml/index.ts @@ -0,0 +1,103 @@ +/** + * Hpml + */ + +import autobind from 'autobind-decorator'; +import { Hpml } from './evaluator'; +import { funcDefs } from './lib'; + +export type Fn = { + slots: string[]; + exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>; +}; + +export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; + +export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = { + text: { out: 'string', category: 'value', icon: 'fas fa-quote-right', }, + multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left', }, + textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list', }, + number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up', }, + ref: { out: null, category: 'value', icon: 'fas fa-magic', }, + aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic', }, + fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt', }, +}; + +export const blockDefs = [ + ...Object.entries(literalDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon + })), + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon + })) +]; + +export type PageVar = { name: string; value: any; type: Type; }; + +export const envVarsDef: Record<string, Type> = { + AI: 'string', + URL: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + SEED: null, + YMD: 'string', + AISCRIPT_DISABLED: 'boolean', + NULL: null, +}; + +export class HpmlScope { + private layerdStates: Record<string, any>[]; + public name: string; + + constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) { + this.layerdStates = layerdStates; + this.name = name || 'anonymous'; + } + + @autobind + public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope { + const layer = [states, ...this.layerdStates]; + return new HpmlScope(layer, name); + } + + /** + * 指定した名前の変数の値を取得します + * @param name 変数名 + */ + @autobind + public getState(name: string): any { + for (const later of this.layerdStates) { + const state = later[name]; + if (state !== undefined) { + return state; + } + } + + throw new HpmlError( + `No such variable '${name}' in scope '${this.name}'`, { + scope: this.layerdStates + }); + } +} + +export class HpmlError extends Error { + public info?: any; + + constructor(message: string, info?: any) { + super(message); + + this.info = info; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HpmlError); + } + } +} diff --git a/packages/client/src/scripts/hpml/lib.ts b/packages/client/src/scripts/hpml/lib.ts new file mode 100644 index 0000000000..2a1ac73a40 --- /dev/null +++ b/packages/client/src/scripts/hpml/lib.ts @@ -0,0 +1,246 @@ +import * as tinycolor from 'tinycolor2'; +import { Hpml } from './evaluator'; +import { values, utils } from '@syuilo/aiscript'; +import { Fn, HpmlScope } from '.'; +import { Expr } from './expr'; +import * as seedrandom from 'seedrandom'; + +/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color +// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs +Chart.pluginService.register({ + beforeDraw: (chart, easing) => { + if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) { + const ctx = chart.chart.ctx; + ctx.save(); + ctx.fillStyle = chart.config.options.chartArea.backgroundColor; + ctx.fillRect(0, 0, chart.chart.width, chart.chart.height); + ctx.restore(); + } + } +}); +*/ + +export function initAiLib(hpml: Hpml) { + return { + 'MkPages:updated': values.FN_NATIVE(([callback]) => { + hpml.pageVarUpdatedCallback = (callback as values.VFn); + }), + 'MkPages:get_canvas': values.FN_NATIVE(([id]) => { + utils.assertString(id); + const canvas = hpml.canvases[id.value]; + const ctx = canvas.getContext('2d'); + return values.OBJ(new Map([ + ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })], + ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })], + ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })], + ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })], + ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })], + ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })], + ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })], + ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })], + ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })], + ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })], + ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })], + ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })], + ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })], + ['fill', values.FN_NATIVE(() => { ctx.fill(); })], + ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })], + ])); + }), + 'MkPages:chart': values.FN_NATIVE(([id, opts]) => { + /* TODO + utils.assertString(id); + utils.assertObject(opts); + const canvas = hpml.canvases[id.value]; + const color = getComputedStyle(document.documentElement).getPropertyValue('--accent'); + Chart.defaults.color = '#555'; + const chart = new Chart(canvas, { + type: opts.value.get('type').value, + data: { + labels: opts.value.get('labels').value.map(x => x.value), + datasets: opts.value.get('datasets').value.map(x => ({ + label: x.value.has('label') ? x.value.get('label').value : '', + data: x.value.get('data').value.map(x => x.value), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.value.has('color') ? x.value.get('color') : color, + backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(), + })) + }, + options: { + responsive: false, + devicePixelRatio: 1.5, + title: { + display: opts.value.has('title'), + text: opts.value.has('title') ? opts.value.get('title').value : '', + fontSize: 14, + }, + layout: { + padding: { + left: 32, + right: 32, + top: opts.value.has('title') ? 16 : 32, + bottom: 16 + } + }, + legend: { + display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true, + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + tooltips: { + enabled: false, + }, + chartArea: { + backgroundColor: '#fff' + }, + ...(opts.value.get('type').value === 'radar' ? { + scale: { + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + maxTicksLimit: 8, + }, + pointLabels: { + fontSize: 12 + } + } + } : { + scales: { + yAxes: [{ + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + } + }] + } + }) + } + }); + */ + }) + }; +} + +export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'fas fa-share-alt', }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle', }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-plus', }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus', }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times', }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', }, + round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator', }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals', }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal', }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than', }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than', }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal', }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal', }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: 'fas fa-quote-right', }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt', }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt', }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt', }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent', }, + listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent', }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, + randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice', }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice', }, // dailyRandomPickWithProbabilityMapping +}; + +export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { + + const date = new Date(); + const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + + const funcs: Record<string, Function> = { + not: (a: boolean) => !a, + or: (a: boolean, b: boolean) => a || b, + and: (a: boolean, b: boolean) => a && b, + eq: (a: any, b: any) => a === b, + notEq: (a: any, b: any) => a !== b, + gt: (a: number, b: number) => a > b, + lt: (a: number, b: number) => a < b, + gtEq: (a: number, b: number) => a >= b, + ltEq: (a: number, b: number) => a <= b, + if: (bool: boolean, a: any, b: any) => bool ? a : b, + for: (times: number, fn: Fn) => { + const result: any[] = []; + for (let i = 0; i < times; i++) { + result.push(fn.exec({ + [fn.slots[0]]: i + 1 + })); + } + return result; + }, + add: (a: number, b: number) => a + b, + subtract: (a: number, b: number) => a - b, + multiply: (a: number, b: number) => a * b, + divide: (a: number, b: number) => a / b, + mod: (a: number, b: number) => a % b, + round: (a: number) => Math.round(a), + strLen: (a: string) => a.length, + strPick: (a: string, b: number) => a[b - 1], + strReplace: (a: string, b: string, c: string) => a.split(b).join(c), + strReverse: (a: string) => a.split('').reverse().join(''), + join: (texts: string[], separator: string) => texts.join(separator || ''), + stringToNumber: (a: string) => parseInt(a), + numberToString: (a: number) => a.toString(), + splitStrByLine: (a: string) => a.split('\n'), + pick: (list: any[], i: number) => list[i - 1], + listLen: (list: any[]) => list.length, + random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability, + rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)), + randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)], + dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability, + dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)), + dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)], + seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability, + seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)), + seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)], + DRPWPM: (list: string[]) => { + const xs: any[] = []; + let totalFactor = 0; + for (const x of list) { + const parts = x.split(' '); + const factor = parseInt(parts.pop()!, 10); + const text = parts.join(' '); + totalFactor += factor; + xs.push({ factor, text }); + } + const r = seedrandom(`${day}:${expr.id}`)() * totalFactor; + let stackedFactor = 0; + for (const x of xs) { + if (r >= stackedFactor && r <= stackedFactor + x.factor) { + return x.text; + } else { + stackedFactor += x.factor; + } + } + return xs[0].text; + }, + }; + + return funcs; +} diff --git a/packages/client/src/scripts/hpml/type-checker.ts b/packages/client/src/scripts/hpml/type-checker.ts new file mode 100644 index 0000000000..9633b3cd01 --- /dev/null +++ b/packages/client/src/scripts/hpml/type-checker.ts @@ -0,0 +1,189 @@ +import autobind from 'autobind-decorator'; +import { Type, envVarsDef, PageVar } from '.'; +import { Expr, isLiteralValue, Variable } from './expr'; +import { funcDefs } from './lib'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +/** + * Hpml type checker + */ +export class HpmlTypeChecker { + public variables: Variable[]; + public pageVars: PageVar[]; + + constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) { + this.variables = variables; + this.pageVars = pageVars; + } + + @autobind + public typeCheck(v: Expr): TypeError | null { + if (isLiteralValue(v)) return null; + + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Expr, slot: number): Type { + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] || null; + } else { + return def.in[slot]; + } + } + + @autobind + public infer(v: Expr): Type { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.infer(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = envVarsDef[v.value || '']; + if (envVar !== undefined) { + return envVar; + } + + return null; + } + if (v.type === 'aiScriptVar') return null; + if (v.type === 'fn') return null; // todo + if (v.type.startsWith('fn:')) return null; // todo + + const generic: Type[] = []; + + const def = funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.infer(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarByName(name: string): Variable { + const v = this.variables.find(x => x.name === name); + if (v !== undefined) { + return v; + } else { + throw new Error(`No such variable '${name}'`); + } + } + + @autobind + public getVarsByType(type: Type): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); + } + + @autobind + public getEnvVarsByType(type: Type): string[] { + if (type == null) return Object.keys(envVarsDef); + return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (envVarsDef[name]) { + return true; + } + + return false; + } +} diff --git a/packages/client/src/scripts/i18n.ts b/packages/client/src/scripts/i18n.ts new file mode 100644 index 0000000000..4fa398763a --- /dev/null +++ b/packages/client/src/scripts/i18n.ts @@ -0,0 +1,29 @@ +export class I18n<T extends Record<string, any>> { + public locale: T; + + constructor(locale: T) { + this.locale = locale; + + //#region BIND + this.t = this.t.bind(this); + //#endregion + } + + // string にしているのは、ドット区切りでのパス指定を許可するため + // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも + public t(key: string, args?: Record<string, any>): string { + try { + let str = key.split('.').reduce((o, i) => o[i], this.locale) as string; + + if (args) { + for (const [k, v] of Object.entries(args)) { + str = str.replace(`{${k}}`, v); + } + } + return str; + } catch (e) { + console.warn(`missing localization '${key}'`); + return key; + } + } +} diff --git a/packages/client/src/scripts/idb-proxy.ts b/packages/client/src/scripts/idb-proxy.ts new file mode 100644 index 0000000000..5f76ae30bb --- /dev/null +++ b/packages/client/src/scripts/idb-proxy.ts @@ -0,0 +1,37 @@ +// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、 +// indexedDBが使えない環境ではlocalStorageを使う +import { + get as iget, + set as iset, + del as idel, +} from 'idb-keyval'; + +const fallbackName = (key: string) => `idbfallback::${key}`; + +let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true; + +if (idbAvailable) { + try { + await iset('idb-test', 'test'); + } catch (e) { + console.error('idb error', e); + idbAvailable = false; + } +} + +if (!idbAvailable) console.error('indexedDB is unavailable. It will use localStorage.'); + +export async function get(key: string) { + if (idbAvailable) return iget(key); + return JSON.parse(localStorage.getItem(fallbackName(key))); +} + +export async function set(key: string, val: any) { + if (idbAvailable) return iset(key, val); + return localStorage.setItem(fallbackName(key), JSON.stringify(val)); +} + +export async function del(key: string) { + if (idbAvailable) return idel(key); + return localStorage.removeItem(fallbackName(key)); +} diff --git a/packages/client/src/scripts/initialize-sw.ts b/packages/client/src/scripts/initialize-sw.ts new file mode 100644 index 0000000000..d6dbd5dbd4 --- /dev/null +++ b/packages/client/src/scripts/initialize-sw.ts @@ -0,0 +1,68 @@ +import { instance } from '@/instance'; +import { $i } from '@/account'; +import { api } from '@/os'; +import { lang } from '@/config'; + +export async function initializeSw() { + if (instance.swPublickey && + ('serviceWorker' in navigator) && + ('PushManager' in window) && + $i && $i.token) { + navigator.serviceWorker.register(`/sw.js`); + + navigator.serviceWorker.ready.then(registration => { + registration.active?.postMessage({ + msg: 'initialize', + lang, + }); + // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(instance.swPublickey) + }).then(subscription => { + function encode(buffer: ArrayBuffer | null) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); + } + + // Register + api('sw/register', { + endpoint: subscription.endpoint, + auth: encode(subscription.getKey('auth')), + publickey: encode(subscription.getKey('p256dh')) + }); + }) + // When subscribe failed + .catch(async (err: Error) => { + // 通知が許可されていなかったとき + if (err.name === 'NotAllowedError') { + return; + } + + // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが + // 既に存在していることが原因でエラーになった可能性があるので、 + // そのサブスクリプションを解除しておく + const subscription = await registration.pushManager.getSubscription(); + if (subscription) subscription.unsubscribe(); + }); + }); + } +} + +/** + * Convert the URL safe base64 string to a Uint8Array + * @param base64String base64 string + */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} diff --git a/packages/client/src/scripts/is-device-darkmode.ts b/packages/client/src/scripts/is-device-darkmode.ts new file mode 100644 index 0000000000..854f38e517 --- /dev/null +++ b/packages/client/src/scripts/is-device-darkmode.ts @@ -0,0 +1,3 @@ +export function isDeviceDarkmode() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/packages/client/src/scripts/is-device-touch.ts b/packages/client/src/scripts/is-device-touch.ts new file mode 100644 index 0000000000..3f0bfefed2 --- /dev/null +++ b/packages/client/src/scripts/is-device-touch.ts @@ -0,0 +1 @@ +export const isDeviceTouch = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; diff --git a/packages/client/src/scripts/is-mobile.ts b/packages/client/src/scripts/is-mobile.ts new file mode 100644 index 0000000000..60cb59f91e --- /dev/null +++ b/packages/client/src/scripts/is-mobile.ts @@ -0,0 +1,2 @@ +const ua = navigator.userAgent.toLowerCase(); +export const isMobile = /mobile|iphone|ipad|android/.test(ua); diff --git a/packages/client/src/scripts/keycode.ts b/packages/client/src/scripts/keycode.ts new file mode 100644 index 0000000000..c127d54bb2 --- /dev/null +++ b/packages/client/src/scripts/keycode.ts @@ -0,0 +1,33 @@ +export default (input: string): string[] => { + if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) { + const codes = aliases[input]; + return Array.isArray(codes) ? codes : [codes]; + } else { + return [input]; + } +}; + +export const aliases = { + 'esc': 'Escape', + 'enter': ['Enter', 'NumpadEnter'], + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['NumpadAdd', 'Semicolon'], +}; + +/*! +* Programatically add the following +*/ + +// lower case chars +for (let i = 97; i < 123; i++) { + const char = String.fromCharCode(i); + aliases[char] = `Key${char.toUpperCase()}`; +} + +// numbers +for (let i = 0; i < 10; i++) { + aliases[i] = [`Numpad${i}`, `Digit${i}`]; +} diff --git a/packages/client/src/scripts/loading.ts b/packages/client/src/scripts/loading.ts new file mode 100644 index 0000000000..4b0a560e34 --- /dev/null +++ b/packages/client/src/scripts/loading.ts @@ -0,0 +1,11 @@ +export default { + start: () => { + // TODO + }, + done: () => { + // TODO + }, + set: val => { + // TODO + } +}; diff --git a/packages/client/src/scripts/login-id.ts b/packages/client/src/scripts/login-id.ts new file mode 100644 index 0000000000..0f9c6be4a9 --- /dev/null +++ b/packages/client/src/scripts/login-id.ts @@ -0,0 +1,11 @@ +export function getUrlWithLoginId(url: string, loginId: string) { + const u = new URL(url, origin); + u.searchParams.append('loginId', loginId); + return u.toString(); +} + +export function getUrlWithoutLoginId(url: string) { + const u = new URL(url); + u.searchParams.delete('loginId'); + return u.toString(); +} diff --git a/packages/client/src/scripts/lookup-user.ts b/packages/client/src/scripts/lookup-user.ts new file mode 100644 index 0000000000..174fa9f879 --- /dev/null +++ b/packages/client/src/scripts/lookup-user.ts @@ -0,0 +1,37 @@ +import * as Acct from 'misskey-js/built/acct'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export async function lookupUser() { + const { canceled, result } = await os.dialog({ + title: i18n.locale.usernameOrUserId, + input: true + }); + if (canceled) return; + + const show = (user) => { + os.pageWindow(`/user-info/${user.id}`); + }; + + const usernamePromise = os.api('users/show', Acct.parse(result)); + const idPromise = os.api('users/show', { userId: result }); + let _notFound = false; + const notFound = () => { + if (_notFound) { + os.dialog({ + type: 'error', + text: i18n.locale.noSuchUser + }); + } else { + _notFound = true; + } + }; + usernamePromise.then(show).catch(e => { + if (e.code === 'NO_SUCH_USER') { + notFound(); + } + }); + idPromise.then(show).catch(e => { + notFound(); + }); +} diff --git a/packages/client/src/scripts/mfm-tags.ts b/packages/client/src/scripts/mfm-tags.ts new file mode 100644 index 0000000000..1b18210aa9 --- /dev/null +++ b/packages/client/src/scripts/mfm-tags.ts @@ -0,0 +1 @@ +export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle']; diff --git a/packages/client/src/scripts/paging.ts b/packages/client/src/scripts/paging.ts new file mode 100644 index 0000000000..ef63ecc450 --- /dev/null +++ b/packages/client/src/scripts/paging.ts @@ -0,0 +1,246 @@ +import { markRaw } from 'vue'; +import * as os from '@/os'; +import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll'; + +const SECOND_FETCH_LIMIT = 30; + +// reversed: items 配列の中身を逆順にする(新しい方が最後) + +export default (opts) => ({ + emits: ['queue'], + + data() { + return { + items: [], + queue: [], + offset: 0, + fetching: true, + moreFetching: false, + inited: false, + more: false, + backed: false, // 遡り中か否か + isBackTop: false, + }; + }, + + computed: { + empty(): boolean { + return this.items.length === 0 && !this.fetching && this.inited; + }, + + error(): boolean { + return !this.fetching && !this.inited; + }, + }, + + watch: { + pagination: { + handler() { + this.init(); + }, + deep: true + }, + + queue: { + handler(a, b) { + if (a.length === 0 && b.length === 0) return; + this.$emit('queue', this.queue.length); + }, + deep: true + } + }, + + created() { + opts.displayLimit = opts.displayLimit || 30; + this.init(); + }, + + activated() { + this.isBackTop = false; + }, + + deactivated() { + this.isBackTop = window.scrollY === 0; + }, + + methods: { + reload() { + this.items = []; + this.init(); + }, + + replaceItem(finder, data) { + const i = this.items.findIndex(finder); + this.items[i] = data; + }, + + removeItem(finder) { + const i = this.items.findIndex(finder); + this.items.splice(i, 1); + }, + + async init() { + this.queue = []; + this.fetching = true; + if (opts.before) opts.before(this); + let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params; + if (params && params.then) params = await params; + if (params === null) return; + const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; + await os.api(endpoint, { + ...params, + limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1, + }).then(items => { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + markRaw(item); + if (this.pagination.reversed) { + if (i === items.length - 2) item._shouldInsertAd_ = true; + } else { + if (i === 3) item._shouldInsertAd_ = true; + } + } + if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) { + items.pop(); + this.items = this.pagination.reversed ? [...items].reverse() : items; + this.more = true; + } else { + this.items = this.pagination.reversed ? [...items].reverse() : items; + this.more = false; + } + this.offset = items.length; + this.inited = true; + this.fetching = false; + if (opts.after) opts.after(this, null); + }, e => { + this.fetching = false; + if (opts.after) opts.after(this, e); + }); + }, + + async fetchMore() { + if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return; + this.moreFetching = true; + this.backed = true; + let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params; + if (params && params.then) params = await params; + const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; + await os.api(endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(this.pagination.offsetMode ? { + offset: this.offset, + } : { + untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, + }), + }).then(items => { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + markRaw(item); + if (this.pagination.reversed) { + if (i === items.length - 9) item._shouldInsertAd_ = true; + } else { + if (i === 10) item._shouldInsertAd_ = true; + } + } + if (items.length > SECOND_FETCH_LIMIT) { + items.pop(); + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); + this.more = true; + } else { + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); + this.more = false; + } + this.offset += items.length; + this.moreFetching = false; + }, e => { + this.moreFetching = false; + }); + }, + + async fetchMoreFeature() { + if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return; + this.moreFetching = true; + let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params; + if (params && params.then) params = await params; + const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; + await os.api(endpoint, { + ...params, + limit: SECOND_FETCH_LIMIT + 1, + ...(this.pagination.offsetMode ? { + offset: this.offset, + } : { + sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id, + }), + }).then(items => { + for (const item of items) { + markRaw(item); + } + if (items.length > SECOND_FETCH_LIMIT) { + items.pop(); + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); + this.more = true; + } else { + this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items); + this.more = false; + } + this.offset += items.length; + this.moreFetching = false; + }, e => { + this.moreFetching = false; + }); + }, + + prepend(item) { + if (this.pagination.reversed) { + const container = getScrollContainer(this.$el); + const pos = getScrollPosition(this.$el); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + const isBottom = (pos + viewHeight > height - 32); + if (isBottom) { + // オーバーフローしたら古いアイテムは捨てる + if (this.items.length >= opts.displayLimit) { + // このやり方だとVue 3.2以降アニメーションが動かなくなる + //this.items = this.items.slice(-opts.displayLimit); + while (this.items.length >= opts.displayLimit) { + this.items.shift(); + } + this.more = true; + } + } + this.items.push(item); + // TODO + } else { + const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); + + if (isTop) { + // Prepend the item + this.items.unshift(item); + + // オーバーフローしたら古いアイテムは捨てる + if (this.items.length >= opts.displayLimit) { + // このやり方だとVue 3.2以降アニメーションが動かなくなる + //this.items = this.items.slice(0, opts.displayLimit); + while (this.items.length >= opts.displayLimit) { + this.items.pop(); + } + this.more = true; + } + } else { + this.queue.push(item); + onScrollTop(this.$el, () => { + for (const item of this.queue) { + this.prepend(item); + } + this.queue = []; + }); + } + } + }, + + append(item) { + this.items.push(item); + }, + } +}); diff --git a/packages/client/src/scripts/physics.ts b/packages/client/src/scripts/physics.ts new file mode 100644 index 0000000000..445b6296eb --- /dev/null +++ b/packages/client/src/scripts/physics.ts @@ -0,0 +1,152 @@ +import * as Matter from 'matter-js'; + +export function physics(container: HTMLElement) { + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + const containerCenterX = containerWidth / 2; + + // サイズ固定化(要らないかも?) + container.style.position = 'relative'; + container.style.boxSizing = 'border-box'; + container.style.width = `${containerWidth}px`; + container.style.height = `${containerHeight}px`; + + // create engine + const engine = Matter.Engine.create({ + constraintIterations: 4, + positionIterations: 8, + velocityIterations: 8, + }); + + const world = engine.world; + + // create renderer + const render = Matter.Render.create({ + engine: engine, + //element: document.getElementById('debug'), + options: { + width: containerWidth, + height: containerHeight, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + } + }); + + // Disable to hide debug + Matter.Render.run(render); + + // create runner + const runner = Matter.Runner.create(); + Matter.Runner.run(runner, engine); + + const groundThickness = 1024; + const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, { + isStatic: true, + restitution: 0.1, + friction: 2 + }); + + //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts); + //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts); + + Matter.World.add(world, [ + ground, + //wallRight, + //wallLeft, + ]); + + const objEls = Array.from(container.children); + const objs = []; + for (const objEl of objEls) { + const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft; + const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop; + + let obj; + if (objEl.classList.contains('_physics_circle_')) { + obj = Matter.Bodies.circle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2, + { + restitution: 0.5 + } + ); + } else { + const style = window.getComputedStyle(objEl); + obj = Matter.Bodies.rectangle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + objEl.offsetWidth, + objEl.offsetHeight, + { + chamfer: { radius: parseInt(style.borderRadius || '0', 10) }, + restitution: 0.5 + } + ); + } + objEl.id = obj.id; + objs.push(obj); + } + + Matter.World.add(engine.world, objs); + + // Add mouse control + + const mouse = Matter.Mouse.create(container); + const mouseConstraint = Matter.MouseConstraint.create(engine, { + mouse: mouse, + constraint: { + stiffness: 0.1, + render: { + visible: false + } + } + }); + + Matter.World.add(engine.world, mouseConstraint); + + // keep the mouse in sync with rendering + render.mouse = mouse; + + for (const objEl of objEls) { + objEl.style.position = `absolute`; + objEl.style.top = 0; + objEl.style.left = 0; + objEl.style.margin = 0; + } + + window.requestAnimationFrame(update); + + let stop = false; + + function update() { + for (const objEl of objEls) { + const obj = objs.find(obj => obj.id.toString() === objEl.id.toString()); + if (obj == null) continue; + + const x = (obj.position.x - objEl.offsetWidth / 2); + const y = (obj.position.y - objEl.offsetHeight / 2); + const angle = obj.angle; + objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`; + } + + if (!stop) { + window.requestAnimationFrame(update); + } + } + + // 奈落に落ちたオブジェクトは消す + const intervalId = setInterval(() => { + for (const obj of objs) { + if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj); + } + }, 1000 * 10); + + return { + stop: () => { + stop = true; + Matter.Runner.stop(runner); + clearInterval(intervalId); + } + }; +} diff --git a/packages/client/src/scripts/please-login.ts b/packages/client/src/scripts/please-login.ts new file mode 100644 index 0000000000..928f6ec0f4 --- /dev/null +++ b/packages/client/src/scripts/please-login.ts @@ -0,0 +1,14 @@ +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { dialog } from '@/os'; + +export function pleaseLogin() { + if ($i) return; + + dialog({ + title: i18n.locale.signinRequired, + text: null + }); + + throw new Error('signin required'); +} diff --git a/packages/client/src/scripts/popout.ts b/packages/client/src/scripts/popout.ts new file mode 100644 index 0000000000..51b8d72868 --- /dev/null +++ b/packages/client/src/scripts/popout.ts @@ -0,0 +1,22 @@ +import * as config from '@/config'; + +export function popout(path: string, w?: HTMLElement) { + let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path; + url += '?zen'; // TODO: ちゃんとURLパースしてクエリ付ける + if (w) { + const position = w.getBoundingClientRect(); + const width = parseInt(getComputedStyle(w, '').width, 10); + const height = parseInt(getComputedStyle(w, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + } else { + const width = 400; + const height = 500; + const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2); + window.open(url, url, + `width=${width}, height=${height}, top=${x}, left=${y}`); + } +} diff --git a/packages/client/src/scripts/reaction-picker.ts b/packages/client/src/scripts/reaction-picker.ts new file mode 100644 index 0000000000..e923326ece --- /dev/null +++ b/packages/client/src/scripts/reaction-picker.ts @@ -0,0 +1,41 @@ +import { Ref, ref } from 'vue'; +import { popup } from '@/os'; + +class ReactionPicker { + private src: Ref<HTMLElement | null> = ref(null); + private manualShowing = ref(false); + private onChosen?: Function; + private onClosed?: Function; + + constructor() { + // nop + } + + public async init() { + await popup(import('@/components/emoji-picker-dialog.vue'), { + src: this.src, + asReactionPicker: true, + manualShowing: this.manualShowing + }, { + done: reaction => { + this.onChosen!(reaction); + }, + close: () => { + this.manualShowing.value = false; + }, + closed: () => { + this.src.value = null; + this.onClosed!(); + } + }); + } + + public show(src: HTMLElement, onChosen: Function, onClosed: Function) { + this.src.value = src; + this.manualShowing.value = true; + this.onChosen = onChosen; + this.onClosed = onClosed; + } +} + +export const reactionPicker = new ReactionPicker(); diff --git a/packages/client/src/scripts/room/furniture.ts b/packages/client/src/scripts/room/furniture.ts new file mode 100644 index 0000000000..7734e32668 --- /dev/null +++ b/packages/client/src/scripts/room/furniture.ts @@ -0,0 +1,21 @@ +export type RoomInfo = { + roomType: string; + carpetColor: string; + furnitures: Furniture[]; +}; + +export type Furniture = { + id: string; // 同じ家具が複数ある場合にそれぞれを識別するためのIDであり、家具IDではない + type: string; // こっちが家具ID(chairとか) + position: { + x: number; + y: number; + z: number; + }; + rotation: { + x: number; + y: number; + z: number; + }; + props?: Record<string, any>; +}; diff --git a/packages/client/src/scripts/room/furnitures.json5 b/packages/client/src/scripts/room/furnitures.json5 new file mode 100644 index 0000000000..4a40994107 --- /dev/null +++ b/packages/client/src/scripts/room/furnitures.json5 @@ -0,0 +1,407 @@ +// 家具メタデータ + +// 家具IDはglbファイル及びそのディレクトリ名と一致する必要があります + +// 家具にはユーザーが設定できるプロパティを設定可能です: +// +// props: { +// <propname>: <proptype> +// } +// +// proptype一覧: +// * image ... 画像選択ダイアログを出し、その画像のURLが格納されます +// * color ... 色選択コントロールを出し、選択された色が格納されます + +// 家具にカスタムテクスチャを適用できるようにするには、textureプロパティに以下の追加の情報を含めます: +// 便宜上そのUVのどの部分にカスタムテクスチャを貼り合わせるかのエリアをテクスチャエリアと呼びます。 +// UVは1024*1024だと仮定します。 +// +// <key>: { +// prop: <プロパティ名>, +// uv: { +// x: <テクスチャエリアX座標>, +// y: <テクスチャエリアY座標>, +// width: <テクスチャエリアの幅>, +// height: <テクスチャエリアの高さ>, +// }, +// } +// +// <key>には、カスタムテクスチャを適用したいメッシュ名を指定します +// <プロパティ名>には、カスタムテクスチャとして使用する画像を格納するプロパティ(前述)名を指定します + +// 家具にカスタムカラーを適用できるようにするには、colorプロパティに以下の追加の情報を含めます: +// +// <key>: <プロパティ名> +// +// <key>には、カスタムカラーを適用したいマテリアル名を指定します +// <プロパティ名>には、カスタムカラーとして使用する色を格納するプロパティ(前述)名を指定します + +[ + { + id: "milk", + place: "floor" + }, + { + id: "bed", + place: "floor" + }, + { + id: "low-table", + place: "floor", + props: { + color: 'color' + }, + color: { + Table: 'color' + } + }, + { + id: "desk", + place: "floor", + props: { + color: 'color' + }, + color: { + Board: 'color' + } + }, + { + id: "chair", + place: "floor", + props: { + color: 'color' + }, + color: { + Chair: 'color' + } + }, + { + id: "chair2", + place: "floor", + props: { + color1: 'color', + color2: 'color' + }, + color: { + Cushion: 'color1', + Leg: 'color2' + } + }, + { + id: "fan", + place: "wall" + }, + { + id: "pc", + place: "floor" + }, + { + id: "plant", + place: "floor" + }, + { + id: "plant2", + place: "floor" + }, + { + id: "eraser", + place: "floor" + }, + { + id: "pencil", + place: "floor" + }, + { + id: "pudding", + place: "floor" + }, + { + id: "cardboard-box", + place: "floor" + }, + { + id: "cardboard-box2", + place: "floor" + }, + { + id: "cardboard-box3", + place: "floor" + }, + { + id: "book", + place: "floor", + props: { + color: 'color' + }, + color: { + Cover: 'color' + } + }, + { + id: "book2", + place: "floor" + }, + { + id: "piano", + place: "floor" + }, + { + id: "facial-tissue", + place: "floor" + }, + { + id: "server", + place: "floor" + }, + { + id: "moon", + place: "floor" + }, + { + id: "corkboard", + place: "wall" + }, + { + id: "mousepad", + place: "floor", + props: { + color: 'color' + }, + color: { + Pad: 'color' + } + }, + { + id: "monitor", + place: "floor", + props: { + screen: 'image' + }, + texture: { + Screen: { + prop: 'screen', + uv: { + x: 0, + y: 434, + width: 1024, + height: 588, + }, + }, + }, + }, + { + id: "tv", + place: "floor", + props: { + screen: 'image' + }, + texture: { + Screen: { + prop: 'screen', + uv: { + x: 0, + y: 434, + width: 1024, + height: 588, + }, + }, + }, + }, + { + id: "keyboard", + place: "floor" + }, + { + id: "carpet-stripe", + place: "floor", + props: { + color1: 'color', + color2: 'color' + }, + color: { + CarpetAreaA: 'color1', + CarpetAreaB: 'color2' + }, + }, + { + id: "mat", + place: "floor", + props: { + color: 'color' + }, + color: { + Mat: 'color' + } + }, + { + id: "color-box", + place: "floor", + props: { + color: 'color' + }, + color: { + main: 'color' + } + }, + { + id: "wall-clock", + place: "wall" + }, + { + id: "cube", + place: "floor", + props: { + color: 'color' + }, + color: { + Cube: 'color' + } + }, + { + id: "photoframe", + place: "wall", + props: { + photo: 'image', + color: 'color' + }, + texture: { + Photo: { + prop: 'photo', + uv: { + x: 0, + y: 342, + width: 1024, + height: 683, + }, + }, + }, + color: { + Frame: 'color' + } + }, + { + id: "pinguin", + place: "floor", + props: { + body: 'color', + belly: 'color' + }, + color: { + Body: 'body', + Belly: 'belly', + } + }, + { + id: "rubik-cube", + place: "floor", + }, + { + id: "poster-h", + place: "wall", + props: { + picture: 'image' + }, + texture: { + Poster: { + prop: 'picture', + uv: { + x: 0, + y: 277, + width: 1024, + height: 745, + }, + }, + }, + }, + { + id: "poster-v", + place: "wall", + props: { + picture: 'image' + }, + texture: { + Poster: { + prop: 'picture', + uv: { + x: 0, + y: 0, + width: 745, + height: 1024, + }, + }, + }, + }, + { + id: "sofa", + place: "floor", + props: { + color: 'color' + }, + color: { + Sofa: 'color' + } + }, + { + id: "spiral", + place: "floor", + props: { + color: 'color' + }, + color: { + Step: 'color' + } + }, + { + id: "bin", + place: "floor", + props: { + color: 'color' + }, + color: { + Bin: 'color' + } + }, + { + id: "cup-noodle", + place: "floor" + }, + { + id: "holo-display", + place: "floor", + props: { + image: 'image' + }, + texture: { + Image_Front: { + prop: 'image', + uv: { + x: 0, + y: 0, + width: 1024, + height: 1024, + }, + }, + Image_Back: { + prop: 'image', + uv: { + x: 0, + y: 0, + width: 1024, + height: 1024, + }, + }, + }, + }, + { + id: 'energy-drink', + place: "floor", + }, + { + id: 'doll-ai', + place: "floor", + }, + { + id: 'banknote', + place: "floor", + }, +] diff --git a/packages/client/src/scripts/room/room.ts b/packages/client/src/scripts/room/room.ts new file mode 100644 index 0000000000..7e04bec646 --- /dev/null +++ b/packages/client/src/scripts/room/room.ts @@ -0,0 +1,775 @@ +import autobind from 'autobind-decorator'; +import { v4 as uuid } from 'uuid'; +import * as THREE from 'three'; +import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; +import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; +import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js'; +import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js'; +import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js'; +import { Furniture, RoomInfo } from './furniture'; +import { query as urlQuery } from '@/scripts/url'; +const furnitureDefs = require('./furnitures.json5'); + +THREE.ImageUtils.crossOrigin = ''; + +type Options = { + graphicsQuality: Room['graphicsQuality']; + onChangeSelect: Room['onChangeSelect']; + useOrthographicCamera: boolean; +}; + +/** + * MisskeyRoom Core Engine + */ +export class Room { + private clock: THREE.Clock; + private scene: THREE.Scene; + private renderer: THREE.WebGLRenderer; + private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera; + private controls: OrbitControls; + private composer: EffectComposer; + private mixers: THREE.AnimationMixer[] = []; + private furnitureControl: TransformControls; + private roomInfo: RoomInfo; + private graphicsQuality: 'cheep' | 'low' | 'medium' | 'high' | 'ultra'; + private roomObj: THREE.Object3D; + private objects: THREE.Object3D[] = []; + private selectedObject: THREE.Object3D = null; + private onChangeSelect: Function; + private isTransformMode = false; + private renderFrameRequestId: number; + + private get canvas(): HTMLCanvasElement { + return this.renderer.domElement; + } + + private get furnitures(): Furniture[] { + return this.roomInfo.furnitures; + } + + private set furnitures(furnitures: Furniture[]) { + this.roomInfo.furnitures = furnitures; + } + + private get enableShadow() { + return this.graphicsQuality != 'cheep'; + } + + private get usePostFXs() { + return this.graphicsQuality !== 'cheep' && this.graphicsQuality !== 'low'; + } + + private get shadowQuality() { + return ( + this.graphicsQuality === 'ultra' ? 16384 : + this.graphicsQuality === 'high' ? 8192 : + this.graphicsQuality === 'medium' ? 4096 : + this.graphicsQuality === 'low' ? 1024 : + 0); // cheep + } + + constructor(user, isMyRoom, roomInfo: RoomInfo, container: Element, options: Options) { + this.roomInfo = roomInfo; + this.graphicsQuality = options.graphicsQuality; + this.onChangeSelect = options.onChangeSelect; + + this.clock = new THREE.Clock(true); + + //#region Init a scene + this.scene = new THREE.Scene(); + + const width = container.clientWidth; + const height = container.clientHeight; + + //#region Init a renderer + this.renderer = new THREE.WebGLRenderer({ + antialias: false, + stencil: false, + alpha: false, + powerPreference: + this.graphicsQuality === 'ultra' ? 'high-performance' : + this.graphicsQuality === 'high' ? 'high-performance' : + this.graphicsQuality === 'medium' ? 'default' : + this.graphicsQuality === 'low' ? 'low-power' : + 'low-power' // cheep + }); + + this.renderer.setPixelRatio(window.devicePixelRatio); + this.renderer.setSize(width, height); + this.renderer.autoClear = false; + this.renderer.setClearColor(new THREE.Color(0x051f2d)); + this.renderer.shadowMap.enabled = this.enableShadow; + this.renderer.shadowMap.type = + this.graphicsQuality === 'ultra' ? THREE.PCFSoftShadowMap : + this.graphicsQuality === 'high' ? THREE.PCFSoftShadowMap : + this.graphicsQuality === 'medium' ? THREE.PCFShadowMap : + this.graphicsQuality === 'low' ? THREE.BasicShadowMap : + THREE.BasicShadowMap; // cheep + + container.insertBefore(this.canvas, container.firstChild); + //#endregion + + //#region Init a camera + this.camera = options.useOrthographicCamera + ? new THREE.OrthographicCamera( + width / - 2, width / 2, height / 2, height / - 2, -10, 10) + : new THREE.PerspectiveCamera(45, width / height); + + if (options.useOrthographicCamera) { + this.camera.position.x = 2; + this.camera.position.y = 2; + this.camera.position.z = 2; + this.camera.zoom = 100; + this.camera.updateProjectionMatrix(); + } else { + this.camera.position.x = 5; + this.camera.position.y = 2; + this.camera.position.z = 5; + } + + this.scene.add(this.camera); + //#endregion + + //#region AmbientLight + const ambientLight = new THREE.AmbientLight(0xffffff, 1); + this.scene.add(ambientLight); + //#endregion + + if (this.graphicsQuality !== 'cheep') { + //#region Room light + const roomLight = new THREE.SpotLight(0xffffff, 0.1); + + roomLight.position.set(0, 8, 0); + roomLight.castShadow = this.enableShadow; + roomLight.shadow.bias = -0.0001; + roomLight.shadow.mapSize.width = this.shadowQuality; + roomLight.shadow.mapSize.height = this.shadowQuality; + roomLight.shadow.camera.near = 0.1; + roomLight.shadow.camera.far = 9; + roomLight.shadow.camera.fov = 45; + + this.scene.add(roomLight); + //#endregion + } + + //#region Out light + const outLight1 = new THREE.SpotLight(0xffffff, 0.4); + outLight1.position.set(9, 3, -2); + outLight1.castShadow = this.enableShadow; + outLight1.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある + outLight1.shadow.mapSize.width = this.shadowQuality; + outLight1.shadow.mapSize.height = this.shadowQuality; + outLight1.shadow.camera.near = 6; + outLight1.shadow.camera.far = 15; + outLight1.shadow.camera.fov = 45; + this.scene.add(outLight1); + + const outLight2 = new THREE.SpotLight(0xffffff, 0.2); + outLight2.position.set(-2, 3, 9); + outLight2.castShadow = false; + outLight2.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある + outLight2.shadow.camera.near = 6; + outLight2.shadow.camera.far = 15; + outLight2.shadow.camera.fov = 45; + this.scene.add(outLight2); + //#endregion + + //#region Init a controller + this.controls = new OrbitControls(this.camera, this.canvas); + + this.controls.target.set(0, 1, 0); + this.controls.enableZoom = true; + this.controls.enablePan = isMyRoom; + this.controls.minPolarAngle = 0; + this.controls.maxPolarAngle = Math.PI / 2; + this.controls.minAzimuthAngle = 0; + this.controls.maxAzimuthAngle = Math.PI / 2; + this.controls.enableDamping = true; + this.controls.dampingFactor = 0.2; + //#endregion + + //#region POST FXs + if (!this.usePostFXs) { + this.composer = null; + } else { + const renderTarget = new THREE.WebGLRenderTarget(width, height, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBFormat, + stencilBuffer: false, + }); + + const fxaa = new ShaderPass(FXAAShader); + fxaa.uniforms['resolution'].value = new THREE.Vector2(1 / width, 1 / height); + fxaa.renderToScreen = true; + + this.composer = new EffectComposer(this.renderer, renderTarget); + this.composer.addPass(new RenderPass(this.scene, this.camera)); + if (this.graphicsQuality === 'ultra') { + this.composer.addPass(new BloomPass(0.25, 30, 128.0, 512)); + } + this.composer.addPass(fxaa); + } + //#endregion + //#endregion + + //#region Label + //#region Avatar + const avatarUrl = `/proxy/?${urlQuery({ url: user.avatarUrl })}`; + + const textureLoader = new THREE.TextureLoader(); + textureLoader.crossOrigin = 'anonymous'; + + const iconTexture = textureLoader.load(avatarUrl); + iconTexture.wrapS = THREE.RepeatWrapping; + iconTexture.wrapT = THREE.RepeatWrapping; + iconTexture.anisotropy = 16; + + const avatarMaterial = new THREE.MeshBasicMaterial({ + map: iconTexture, + side: THREE.DoubleSide, + alphaTest: 0.5 + }); + + const iconGeometry = new THREE.PlaneGeometry(1, 1); + + const avatarObject = new THREE.Mesh(iconGeometry, avatarMaterial); + avatarObject.position.set(-3, 2.5, 2); + avatarObject.rotation.y = Math.PI / 2; + avatarObject.castShadow = false; + + this.scene.add(avatarObject); + //#endregion + + //#region Username + const name = user.username; + + new THREE.FontLoader().load('/assets/fonts/helvetiker_regular.typeface.json', font => { + const nameGeometry = new THREE.TextGeometry(name, { + size: 0.5, + height: 0, + curveSegments: 8, + font: font, + bevelThickness: 0, + bevelSize: 0, + bevelEnabled: false + }); + + const nameMaterial = new THREE.MeshLambertMaterial({ + color: 0xffffff + }); + + const nameObject = new THREE.Mesh(nameGeometry, nameMaterial); + nameObject.position.set(-3, 2.25, 1.25); + nameObject.rotation.y = Math.PI / 2; + nameObject.castShadow = false; + + this.scene.add(nameObject); + }); + //#endregion + //#endregion + + //#region Interaction + if (isMyRoom) { + this.furnitureControl = new TransformControls(this.camera, this.canvas); + this.scene.add(this.furnitureControl); + + // Hover highlight + this.canvas.onmousemove = this.onmousemove; + + // Click + this.canvas.onmousedown = this.onmousedown; + } + //#endregion + + //#region Init room + this.loadRoom(); + //#endregion + + //#region Load furnitures + for (const furniture of this.furnitures) { + this.loadFurniture(furniture).then(obj => { + this.scene.add(obj.scene); + this.objects.push(obj.scene); + }); + } + //#endregion + + // Start render + if (this.usePostFXs) { + this.renderWithPostFXs(); + } else { + this.renderWithoutPostFXs(); + } + } + + @autobind + private renderWithoutPostFXs() { + this.renderFrameRequestId = + window.requestAnimationFrame(this.renderWithoutPostFXs); + + // Update animations + const clock = this.clock.getDelta(); + for (const mixer of this.mixers) { + mixer.update(clock); + } + + this.controls.update(); + this.renderer.render(this.scene, this.camera); + } + + @autobind + private renderWithPostFXs() { + this.renderFrameRequestId = + window.requestAnimationFrame(this.renderWithPostFXs); + + // Update animations + const clock = this.clock.getDelta(); + for (const mixer of this.mixers) { + mixer.update(clock); + } + + this.controls.update(); + this.renderer.clear(); + this.composer.render(); + } + + @autobind + private loadRoom() { + const type = this.roomInfo.roomType; + new GLTFLoader().load(`/client-assets/room/rooms/${type}/${type}.glb`, gltf => { + gltf.scene.traverse(child => { + if (!(child instanceof THREE.Mesh)) return; + + child.receiveShadow = this.enableShadow; + + child.material = new THREE.MeshLambertMaterial({ + color: (child.material as THREE.MeshStandardMaterial).color, + map: (child.material as THREE.MeshStandardMaterial).map, + name: (child.material as THREE.MeshStandardMaterial).name, + }); + + // 異方性フィルタリング + if ((child.material as THREE.MeshLambertMaterial).map && this.graphicsQuality !== 'cheep') { + (child.material as THREE.MeshLambertMaterial).map.minFilter = THREE.LinearMipMapLinearFilter; + (child.material as THREE.MeshLambertMaterial).map.magFilter = THREE.LinearMipMapLinearFilter; + (child.material as THREE.MeshLambertMaterial).map.anisotropy = 8; + } + }); + + gltf.scene.position.set(0, 0, 0); + + this.scene.add(gltf.scene); + this.roomObj = gltf.scene; + if (this.roomInfo.roomType === 'default') { + this.applyCarpetColor(); + } + }); + } + + @autobind + private loadFurniture(furniture: Furniture) { + const def = furnitureDefs.find(d => d.id === furniture.type); + return new Promise<GLTF>((res, rej) => { + const loader = new GLTFLoader(); + loader.load(`/client-assets/room/furnitures/${furniture.type}/${furniture.type}.glb`, gltf => { + const model = gltf.scene; + + // Load animation + if (gltf.animations.length > 0) { + const mixer = new THREE.AnimationMixer(model); + this.mixers.push(mixer); + for (const clip of gltf.animations) { + mixer.clipAction(clip).play(); + } + } + + model.name = furniture.id; + model.position.x = furniture.position.x; + model.position.y = furniture.position.y; + model.position.z = furniture.position.z; + model.rotation.x = furniture.rotation.x; + model.rotation.y = furniture.rotation.y; + model.rotation.z = furniture.rotation.z; + + model.traverse(child => { + if (!(child instanceof THREE.Mesh)) return; + child.castShadow = this.enableShadow; + child.receiveShadow = this.enableShadow; + (child.material as THREE.MeshStandardMaterial).metalness = 0; + + // 異方性フィルタリング + if ((child.material as THREE.MeshStandardMaterial).map && this.graphicsQuality !== 'cheep') { + (child.material as THREE.MeshStandardMaterial).map.minFilter = THREE.LinearMipMapLinearFilter; + (child.material as THREE.MeshStandardMaterial).map.magFilter = THREE.LinearMipMapLinearFilter; + (child.material as THREE.MeshStandardMaterial).map.anisotropy = 8; + } + }); + + if (def.color) { // カスタムカラー + this.applyCustomColor(model); + } + + if (def.texture) { // カスタムテクスチャ + this.applyCustomTexture(model); + } + + res(gltf); + }, null, rej); + }); + } + + @autobind + private applyCarpetColor() { + this.roomObj.traverse(child => { + if (!(child instanceof THREE.Mesh)) return; + if (child.material && + (child.material as THREE.MeshStandardMaterial).name && + (child.material as THREE.MeshStandardMaterial).name === 'Carpet' + ) { + const colorHex = parseInt(this.roomInfo.carpetColor.substr(1), 16); + (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex); + } + }); + } + + @autobind + private applyCustomColor(model: THREE.Object3D) { + const furniture = this.furnitures.find(furniture => furniture.id === model.name); + const def = furnitureDefs.find(d => d.id === furniture.type); + if (def.color == null) return; + model.traverse(child => { + if (!(child instanceof THREE.Mesh)) return; + for (const t of Object.keys(def.color)) { + if (!child.material || + !(child.material as THREE.MeshStandardMaterial).name || + (child.material as THREE.MeshStandardMaterial).name !== t + ) continue; + + const prop = def.color[t]; + const val = furniture.props ? furniture.props[prop] : undefined; + + if (val == null) continue; + + const colorHex = parseInt(val.substr(1), 16); + (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex); + } + }); + } + + @autobind + private applyCustomTexture(model: THREE.Object3D) { + const furniture = this.furnitures.find(furniture => furniture.id === model.name); + const def = furnitureDefs.find(d => d.id === furniture.type); + if (def.texture == null) return; + + model.traverse(child => { + if (!(child instanceof THREE.Mesh)) return; + for (const t of Object.keys(def.texture)) { + if (child.name !== t) continue; + + const prop = def.texture[t].prop; + const val = furniture.props ? furniture.props[prop] : undefined; + + if (val == null) continue; + + const canvas = document.createElement('canvas'); + canvas.height = 1024; + canvas.width = 1024; + + child.material = new THREE.MeshLambertMaterial({ + emissive: 0x111111, + side: THREE.DoubleSide, + alphaTest: 0.5, + }); + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + const uvInfo = def.texture[t].uv; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, + 0, 0, img.width, img.height, + uvInfo.x, uvInfo.y, uvInfo.width, uvInfo.height); + + const texture = new THREE.Texture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + texture.anisotropy = 16; + texture.flipY = false; + + (child.material as THREE.MeshLambertMaterial).map = texture; + (child.material as THREE.MeshLambertMaterial).needsUpdate = true; + (child.material as THREE.MeshLambertMaterial).map.needsUpdate = true; + }; + img.src = val; + } + }); + } + + @autobind + private onmousemove(ev: MouseEvent) { + if (this.isTransformMode) return; + + const rect = (ev.target as HTMLElement).getBoundingClientRect(); + const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; + const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; + const pos = new THREE.Vector2(x, y); + + this.camera.updateMatrixWorld(); + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(pos, this.camera); + + const intersects = raycaster.intersectObjects(this.objects, true); + + for (const object of this.objects) { + if (this.isSelectedObject(object)) continue; + object.traverse(child => { + if (child instanceof THREE.Mesh) { + (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000); + } + }); + } + + if (intersects.length > 0) { + const intersected = this.getRoot(intersects[0].object); + if (this.isSelectedObject(intersected)) return; + intersected.traverse(child => { + if (child instanceof THREE.Mesh) { + (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x191919); + } + }); + } + } + + @autobind + private onmousedown(ev: MouseEvent) { + if (this.isTransformMode) return; + if (ev.target !== this.canvas || ev.button !== 0) return; + + const rect = (ev.target as HTMLElement).getBoundingClientRect(); + const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; + const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; + const pos = new THREE.Vector2(x, y); + + this.camera.updateMatrixWorld(); + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(pos, this.camera); + + const intersects = raycaster.intersectObjects(this.objects, true); + + for (const object of this.objects) { + object.traverse(child => { + if (child instanceof THREE.Mesh) { + (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000); + } + }); + } + + if (intersects.length > 0) { + const selectedObj = this.getRoot(intersects[0].object); + this.selectFurniture(selectedObj); + } else { + this.selectedObject = null; + this.onChangeSelect(null); + } + } + + @autobind + private getRoot(obj: THREE.Object3D): THREE.Object3D { + let found = false; + let x = obj.parent; + while (!found) { + if (x.parent.parent == null) { + found = true; + } else { + x = x.parent; + } + } + return x; + } + + @autobind + private isSelectedObject(obj: THREE.Object3D): boolean { + if (this.selectedObject == null) { + return false; + } else { + return obj.name === this.selectedObject.name; + } + } + + @autobind + private selectFurniture(obj: THREE.Object3D) { + this.selectedObject = obj; + this.onChangeSelect(obj); + obj.traverse(child => { + if (child instanceof THREE.Mesh) { + (child.material as THREE.MeshStandardMaterial).emissive.setHex(0xff0000); + } + }); + } + + /** + * 家具の移動/回転モードにします + * @param type 移動か回転か + */ + @autobind + public enterTransformMode(type: 'translate' | 'rotate') { + this.isTransformMode = true; + this.furnitureControl.setMode(type); + this.furnitureControl.attach(this.selectedObject); + this.controls.enableRotate = false; + } + + /** + * 家具の移動/回転モードを終了します + */ + @autobind + public exitTransformMode() { + this.isTransformMode = false; + this.furnitureControl.detach(); + this.controls.enableRotate = true; + } + + /** + * 家具プロパティを更新します + * @param key プロパティ名 + * @param value 値 + */ + @autobind + public updateProp(key: string, value: any) { + const furniture = this.furnitures.find(furniture => furniture.id === this.selectedObject.name); + if (furniture.props == null) furniture.props = {}; + furniture.props[key] = value; + this.applyCustomColor(this.selectedObject); + this.applyCustomTexture(this.selectedObject); + } + + /** + * 部屋に家具を追加します + * @param type 家具の種類 + */ + @autobind + public addFurniture(type: string) { + const furniture = { + id: uuid(), + type: type, + position: { + x: 0, + y: 0, + z: 0, + }, + rotation: { + x: 0, + y: 0, + z: 0, + }, + }; + + this.furnitures.push(furniture); + + this.loadFurniture(furniture).then(obj => { + this.scene.add(obj.scene); + this.objects.push(obj.scene); + }); + } + + /** + * 現在選択されている家具を部屋から削除します + */ + @autobind + public removeFurniture() { + this.exitTransformMode(); + const obj = this.selectedObject; + this.scene.remove(obj); + this.objects = this.objects.filter(object => object.name !== obj.name); + this.furnitures = this.furnitures.filter(furniture => furniture.id !== obj.name); + this.selectedObject = null; + this.onChangeSelect(null); + } + + /** + * 全ての家具を部屋から削除します + */ + @autobind + public removeAllFurnitures() { + this.exitTransformMode(); + for (const obj of this.objects) { + this.scene.remove(obj); + } + this.objects = []; + this.furnitures = []; + this.selectedObject = null; + this.onChangeSelect(null); + } + + /** + * 部屋の床の色を変更します + * @param color 色 + */ + @autobind + public updateCarpetColor(color: string) { + this.roomInfo.carpetColor = color; + this.applyCarpetColor(); + } + + /** + * 部屋の種類を変更します + * @param type 種類 + */ + @autobind + public changeRoomType(type: string) { + this.roomInfo.roomType = type; + this.scene.remove(this.roomObj); + this.loadRoom(); + } + + /** + * 部屋データを取得します + */ + @autobind + public getRoomInfo() { + for (const obj of this.objects) { + const furniture = this.furnitures.find(f => f.id === obj.name); + furniture.position.x = obj.position.x; + furniture.position.y = obj.position.y; + furniture.position.z = obj.position.z; + furniture.rotation.x = obj.rotation.x; + furniture.rotation.y = obj.rotation.y; + furniture.rotation.z = obj.rotation.z; + } + + return this.roomInfo; + } + + /** + * 選択されている家具を取得します + */ + @autobind + public getSelectedObject() { + return this.selectedObject; + } + + @autobind + public findFurnitureById(id: string) { + return this.furnitures.find(furniture => furniture.id === id); + } + + /** + * レンダリングを終了します + */ + @autobind + public destroy() { + // Stop render loop + window.cancelAnimationFrame(this.renderFrameRequestId); + + this.controls.dispose(); + this.scene.dispose(); + } +} diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts new file mode 100644 index 0000000000..621fe88105 --- /dev/null +++ b/packages/client/src/scripts/scroll.ts @@ -0,0 +1,80 @@ +type ScrollBehavior = 'auto' | 'smooth' | 'instant'; + +export function getScrollContainer(el: Element | null): Element | null { + if (el == null || el.tagName === 'BODY') return null; + const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); + if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる + return el; + } else { + return getScrollContainer(el.parentElement); + } +} + +export function getScrollPosition(el: Element | null): number { + const container = getScrollContainer(el); + return container == null ? window.scrollY : container.scrollTop; +} + +export function isTopVisible(el: Element | null): boolean { + const scrollTop = getScrollPosition(el); + const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる + + return scrollTop <= topPosition; +} + +export function onScrollTop(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + if (isTopVisible(el)) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function onScrollBottom(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + const pos = getScrollPosition(el); + if (pos + el.clientHeight > el.scrollHeight - 1) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function scroll(el: Element, options: { + top?: number; + left?: number; + behavior?: ScrollBehavior; +}) { + const container = getScrollContainer(el); + if (container == null) { + window.scroll(options); + } else { + container.scroll(options); + } +} + +export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 0, ...options }); +} + +export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する +} + +export function isBottom(el: Element, asobi = 0) { + const container = getScrollContainer(el); + const current = container + ? el.scrollTop + el.offsetHeight + : window.scrollY + window.innerHeight; + const max = container + ? el.scrollHeight + : document.body.offsetHeight; + return current >= (max - asobi); +} diff --git a/packages/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts new file mode 100644 index 0000000000..b28cccfab7 --- /dev/null +++ b/packages/client/src/scripts/search.ts @@ -0,0 +1,64 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { router } from '@/router'; + +export async function search() { + const { canceled, result: query } = await os.dialog({ + title: i18n.locale.search, + input: true + }); + if (canceled || query == null || query === '') return; + + const q = query.trim(); + + if (q.startsWith('@') && !q.includes(' ')) { + router.push(`/${q}`); + return; + } + + if (q.startsWith('#')) { + router.push(`/tags/${encodeURIComponent(q.substr(1))}`); + return; + } + + // like 2018/03/12 + if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) { + const date = new Date(q.replace(/-/g, '/')); + + // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは + // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので + // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の + // 結果になってしまい、2018/03/12 のコンテンツは含まれない) + if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { + date.setHours(23, 59, 59, 999); + } + + // TODO + //v.$root.$emit('warp', date); + os.dialog({ + icon: 'fas fa-history', + iconOnly: true, autoClose: true + }); + return; + } + + if (q.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: q + }); + + os.promiseDialog(promise, null, null, i18n.locale.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + router.push(`/notes/${res.object.id}`); + } + + return; + } + + router.push(`/search?q=${encodeURIComponent(q)}`); +} diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts new file mode 100644 index 0000000000..5fbc545b26 --- /dev/null +++ b/packages/client/src/scripts/select-file.ts @@ -0,0 +1,89 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; + +export function selectFile(src: any, label: string | null, multiple = false) { + return new Promise((res, rej) => { + const chooseFileFromPc = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = multiple; + input.onchange = () => { + const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder)); + + Promise.all(promises).then(driveFiles => { + res(multiple ? driveFiles : driveFiles[0]); + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); + }; + + const chooseFileFromDrive = () => { + os.selectDriveFile(multiple).then(files => { + res(files); + }); + }; + + const chooseFileFromUrl = () => { + os.dialog({ + title: i18n.locale.uploadFromUrl, + input: { + placeholder: i18n.locale.uploadFromUrlDescription + } + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + const connection = os.stream.useChannel('main'); + connection.on('urlUploadFinished', data => { + if (data.marker === marker) { + res(multiple ? [data.file] : data.file); + connection.dispose(); + } + }); + + os.api('drive/files/upload-from-url', { + url: url, + folderId: defaultStore.state.uploadFolder, + marker + }); + + os.dialog({ + title: i18n.locale.uploadFromUrlRequested, + text: i18n.locale.uploadFromUrlMayTakeTime + }); + }); + }; + + os.popupMenu([label ? { + text: label, + type: 'label' + } : undefined, { + text: i18n.locale.upload, + icon: 'fas fa-upload', + action: chooseFileFromPc + }, { + text: i18n.locale.fromDrive, + icon: 'fas fa-cloud', + action: chooseFileFromDrive + }, { + text: i18n.locale.fromUrl, + icon: 'fas fa-link', + action: chooseFileFromUrl + }], src); + }); +} diff --git a/packages/client/src/scripts/show-suspended-dialog.ts b/packages/client/src/scripts/show-suspended-dialog.ts new file mode 100644 index 0000000000..3bc4800030 --- /dev/null +++ b/packages/client/src/scripts/show-suspended-dialog.ts @@ -0,0 +1,10 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +export function showSuspendedDialog() { + return os.dialog({ + type: 'error', + title: i18n.locale.yourAccountSuspendedTitle, + text: i18n.locale.yourAccountSuspendedDescription + }); +} diff --git a/packages/client/src/scripts/sound.ts b/packages/client/src/scripts/sound.ts new file mode 100644 index 0000000000..2b8279b3df --- /dev/null +++ b/packages/client/src/scripts/sound.ts @@ -0,0 +1,34 @@ +import { ColdDeviceStorage } from '@/store'; + +const cache = new Map<string, HTMLAudioElement>(); + +export function getAudio(file: string, useCache = true): HTMLAudioElement { + let audio: HTMLAudioElement; + if (useCache && cache.has(file)) { + audio = cache.get(file); + } else { + audio = new Audio(`/client-assets/sounds/${file}.mp3`); + if (useCache) cache.set(file, audio); + } + return audio; +} + +export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + audio.volume = masterVolume - ((1 - volume) * masterVolume); + return audio; +} + +export function play(type: string) { + const sound = ColdDeviceStorage.get('sound_' + type as any); + if (sound.type == null) return; + playFile(sound.type, sound.volume); +} + +export function playFile(file: string, volume: number) { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + if (masterVolume === 0) return; + + const audio = setVolume(getAudio(file), volume); + audio.play(); +} diff --git a/packages/client/src/scripts/sticky-sidebar.ts b/packages/client/src/scripts/sticky-sidebar.ts new file mode 100644 index 0000000000..c67b8f37ac --- /dev/null +++ b/packages/client/src/scripts/sticky-sidebar.ts @@ -0,0 +1,50 @@ +export class StickySidebar { + private lastScrollTop = 0; + private container: HTMLElement; + private el: HTMLElement; + private spacer: HTMLElement; + private marginTop: number; + private isTop = false; + private isBottom = false; + private offsetTop: number; + private globalHeaderHeight: number = 59; + + constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) { + this.container = container; + this.el = this.container.children[0] as HTMLElement; + this.el.style.position = 'sticky'; + this.spacer = document.createElement('div'); + this.container.prepend(this.spacer); + this.marginTop = marginTop; + this.offsetTop = this.container.getBoundingClientRect().top; + this.globalHeaderHeight = globalHeaderHeight; + } + + public calc(scrollTop: number) { + if (scrollTop > this.lastScrollTop) { // downscroll + const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight); + this.el.style.bottom = null; + this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`; + + this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight); + + if (this.isTop) { + this.isTop = false; + this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`; + } + } else { // upscroll + const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight; + this.el.style.top = null; + this.el.style.bottom = `${-overflow}px`; + + this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop; + + if (this.isBottom) { + this.isBottom = false; + this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`; + } + } + + this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; + } +} diff --git a/packages/client/src/scripts/theme-editor.ts b/packages/client/src/scripts/theme-editor.ts new file mode 100644 index 0000000000..3d69d2836a --- /dev/null +++ b/packages/client/src/scripts/theme-editor.ts @@ -0,0 +1,81 @@ +import { v4 as uuid} from 'uuid'; + +import { themeProps, Theme } from './theme'; + +export type Default = null; +export type Color = string; +export type FuncName = 'alpha' | 'darken' | 'lighten'; +export type Func = { type: 'func'; name: FuncName; arg: number; value: string; }; +export type RefProp = { type: 'refProp'; key: string; }; +export type RefConst = { type: 'refConst'; key: string; }; +export type Css = { type: 'css'; value: string; }; + +export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default; + +export type ThemeViewModel = [ string, ThemeValue ][]; + +export const fromThemeString = (str?: string) : ThemeValue => { + if (!str) return null; + if (str.startsWith(':')) { + const parts = str.slice(1).split('<'); + const name = parts[0] as FuncName; + const arg = parseFloat(parts[1]); + const value = parts[2].startsWith('@') ? parts[2].slice(1) : ''; + return { type: 'func', name, arg, value }; + } else if (str.startsWith('@')) { + return { + type: 'refProp', + key: str.slice(1), + }; + } else if (str.startsWith('$')) { + return { + type: 'refConst', + key: str.slice(1), + }; + } else if (str.startsWith('"')) { + return { + type: 'css', + value: str.substr(1).trim(), + }; + } else { + return str; + } +}; + +export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => { + if (typeof value === 'string') return value; + switch (value.type) { + case 'func': return `:${value.name}<${value.arg}<@${value.value}`; + case 'refProp': return `@${value.key}`; + case 'refConst': return `$${value.key}`; + case 'css': return `" ${value.value}`; + } +}; + +export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => { + const props = { } as { [key: string]: string }; + for (const [ key, value ] of vm) { + if (value === null) continue; + props[key] = toThemeString(value); + } + + return { + id: uuid(), + name, desc, author, props, base + }; +}; + +export const convertToViewModel = (theme: Theme): ThemeViewModel => { + const vm: ThemeViewModel = []; + // プロパティの登録 + vm.push(...themeProps.map(key => [ key, fromThemeString(theme.props[key])] as [ string, ThemeValue ])); + + // 定数の登録 + const consts = Object + .keys(theme.props) + .filter(k => k.startsWith('$')) + .map(k => [ k, fromThemeString(theme.props[k]) ] as [ string, ThemeValue ]); + + vm.push(...consts); + return vm; +}; diff --git a/packages/client/src/scripts/theme.ts b/packages/client/src/scripts/theme.ts new file mode 100644 index 0000000000..3b7f003d0f --- /dev/null +++ b/packages/client/src/scripts/theme.ts @@ -0,0 +1,127 @@ +import { globalEvents } from '@/events'; +import * as tinycolor from 'tinycolor2'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record<string, string>; +}; + +export const lightTheme: Theme = require('@/themes/_light.json5'); +export const darkTheme: Theme = require('@/themes/_dark.json5'); + +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + +export const builtinThemes = [ + require('@/themes/l-light.json5'), + require('@/themes/l-apricot.json5'), + require('@/themes/l-rainy.json5'), + require('@/themes/l-vivid.json5'), + require('@/themes/l-sushi.json5'), + + require('@/themes/d-dark.json5'), + require('@/themes/d-persimmon.json5'), + require('@/themes/d-astro.json5'), + require('@/themes/d-future.json5'), + require('@/themes/d-botanical.json5'), + require('@/themes/d-pumpkin.json5'), + require('@/themes/d-black.json5'), +] as Theme[]; + +let timeout = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) clearTimeout(timeout); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + // Deep copy + const _theme = JSON.parse(JSON.stringify(theme)); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['html']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + } + + if (persist) { + localStorage.setItem('theme', JSON.stringify(props)); + } + + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); +} + +function compile(theme: Theme): Record<string, string> { + function getColor(val: string): tinycolor.Instance { + // ref (prop) + if (val[0] === '@') { + return getColor(theme.props[val.substr(1)]); + } + + // ref (const) + else if (val[0] === '$') { + return getColor(theme.props[val]); + } + + // func + else if (val[0] === ':') { + const parts = val.split('<'); + const func = parts.shift().substr(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + + // other case + return tinycolor(val); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} + +export function validateTheme(theme: Record<string, any>): boolean { + if (theme.id == null || typeof theme.id !== 'string') return false; + if (theme.name == null || typeof theme.name !== 'string') return false; + if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false; + if (theme.props == null || typeof theme.props !== 'object') return false; + return true; +} diff --git a/packages/client/src/scripts/time.ts b/packages/client/src/scripts/time.ts new file mode 100644 index 0000000000..34e8b6b17c --- /dev/null +++ b/packages/client/src/scripts/time.ts @@ -0,0 +1,39 @@ +const dateTimeIntervals = { + 'day': 86400000, + 'hour': 3600000, + 'ms': 1, +}; + +export function dateUTC(time: number[]): Date { + const d = time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; + + if (!d) throw 'wrong number of arguments'; + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) > 0; +} + +export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() + (value * dateTimeIntervals[span])); +} + +export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() - (value * dateTimeIntervals[span])); +} diff --git a/packages/client/src/scripts/twemoji-base.ts b/packages/client/src/scripts/twemoji-base.ts new file mode 100644 index 0000000000..cd50311b15 --- /dev/null +++ b/packages/client/src/scripts/twemoji-base.ts @@ -0,0 +1 @@ +export const twemojiSvgBase = '/twemoji'; diff --git a/packages/client/src/scripts/unison-reload.ts b/packages/client/src/scripts/unison-reload.ts new file mode 100644 index 0000000000..59af584c1b --- /dev/null +++ b/packages/client/src/scripts/unison-reload.ts @@ -0,0 +1,15 @@ +// SafariがBroadcastChannel未実装なのでライブラリを使う +import { BroadcastChannel } from 'broadcast-channel'; + +export const reloadChannel = new BroadcastChannel<string | null>('reload'); + +// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。 +export function unisonReload(path?: string) { + if (path !== undefined) { + reloadChannel.postMessage(path); + location.href = path; + } else { + reloadChannel.postMessage(null); + location.reload(); + } +} diff --git a/packages/client/src/scripts/url.ts b/packages/client/src/scripts/url.ts new file mode 100644 index 0000000000..c7f2b7c1e7 --- /dev/null +++ b/packages/client/src/scripts/url.ts @@ -0,0 +1,13 @@ +export function query(obj: {}): string { + const params = Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + .reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>); + + return Object.entries(params) + .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`) + .join('&'); +} + +export function appendQuery(url: string, query: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +} diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts new file mode 100644 index 0000000000..955d94a074 --- /dev/null +++ b/packages/client/src/store.ts @@ -0,0 +1,318 @@ +import { markRaw, ref } from 'vue'; +import { Storage } from './pizzax'; +import { Theme } from './scripts/theme'; + +export const postFormActions = []; +export const userActions = []; +export const noteActions = []; +export const noteViewInterruptors = []; +export const notePostInterruptors = []; + +// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) +// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない +export const defaultStore = markRaw(new Storage('base', { + tutorial: { + where: 'account', + default: 0 + }, + keepCw: { + where: 'account', + default: false + }, + showFullAcct: { + where: 'account', + default: false + }, + rememberNoteVisibility: { + where: 'account', + default: false + }, + defaultNoteVisibility: { + where: 'account', + default: 'public' + }, + defaultNoteLocalOnly: { + where: 'account', + default: false + }, + uploadFolder: { + where: 'account', + default: null as string | null + }, + pastedFileName: { + where: 'account', + default: 'yyyy-MM-dd HH-mm-ss [{{number}}]' + }, + memo: { + where: 'account', + default: null + }, + reactions: { + where: 'account', + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'] + }, + mutedWords: { + where: 'account', + default: [] + }, + mutedAds: { + where: 'account', + default: [] as string[] + }, + + menu: { + where: 'deviceAccount', + default: [ + 'notifications', + 'messaging', + 'drive', + 'followRequests', + '-', + 'gallery', + 'featured', + 'explore', + 'announcements', + 'search', + '-', + 'ui', + ] + }, + visibility: { + where: 'deviceAccount', + default: 'public' as 'public' | 'home' | 'followers' | 'specified' + }, + localOnly: { + where: 'deviceAccount', + default: false + }, + widgets: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + place: string | null; + data: Record<string, any>; + }[] + }, + tl: { + where: 'deviceAccount', + default: { + src: 'home', + arg: null + } + }, + + serverDisconnectedBehavior: { + where: 'device', + default: 'quiet' as 'quiet' | 'reload' | 'dialog' + }, + nsfw: { + where: 'device', + default: 'respect' as 'respect' | 'force' | 'ignore' + }, + animation: { + where: 'device', + default: true + }, + animatedMfm: { + where: 'device', + default: true + }, + loadRawImages: { + where: 'device', + default: false + }, + imageNewTab: { + where: 'device', + default: false + }, + disableShowingAnimatedImages: { + where: 'device', + default: false + }, + disablePagesScript: { + where: 'device', + default: false + }, + useOsNativeEmojis: { + where: 'device', + default: false + }, + useBlurEffectForModal: { + where: 'device', + default: true + }, + useBlurEffect: { + where: 'device', + default: true + }, + showFixedPostForm: { + where: 'device', + default: false + }, + enableInfiniteScroll: { + where: 'device', + default: true + }, + useReactionPickerForContextMenu: { + where: 'device', + default: true + }, + showGapBetweenNotesInTimeline: { + where: 'device', + default: false + }, + darkMode: { + where: 'device', + default: false + }, + instanceTicker: { + where: 'device', + default: 'remote' as 'none' | 'remote' | 'always' + }, + reactionPickerWidth: { + where: 'device', + default: 1 + }, + reactionPickerHeight: { + where: 'device', + default: 1 + }, + recentlyUsedEmojis: { + where: 'device', + default: [] as string[] + }, + recentlyUsedUsers: { + where: 'device', + default: [] as string[] + }, + defaultSideView: { + where: 'device', + default: false + }, + menuDisplay: { + where: 'device', + default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top' + }, + reportError: { + where: 'device', + default: false + }, + squareAvatars: { + where: 'device', + default: false + }, + postFormWithHashtags: { + where: 'device', + default: false + }, + postFormHashtags: { + where: 'device', + default: '' + }, + aiChanMode: { + where: 'device', + default: false + }, +})); + +// TODO: 他のタブと永続化されたstateを同期 + +const PREFIX = 'miux:'; + +type Plugin = { + id: string; + name: string; + active: boolean; + configData: Record<string, any>; + token: string; + ast: any[]; +}; + +/** + * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) + */ +export class ColdDeviceStorage { + public static default = { + lightTheme: require('@/themes/l-light.json5') as Theme, + darkTheme: require('@/themes/d-dark.json5') as Theme, + syncDeviceDarkMode: true, + chatOpenBehavior: 'page' as 'page' | 'window' | 'popout', + plugins: [] as Plugin[], + mediaVolume: 0.5, + sound_masterVolume: 0.3, + sound_note: { type: 'syuilo/down', volume: 1 }, + sound_noteMy: { type: 'syuilo/up', volume: 1 }, + sound_notification: { type: 'syuilo/pope2', volume: 1 }, + sound_chat: { type: 'syuilo/pope1', volume: 1 }, + sound_chatBg: { type: 'syuilo/waon', volume: 1 }, + sound_antenna: { type: 'syuilo/triple', volume: 1 }, + sound_channel: { type: 'syuilo/square-pico', volume: 1 }, + sound_reversiPutBlack: { type: 'syuilo/kick', volume: 0.3 }, + sound_reversiPutWhite: { type: 'syuilo/snare', volume: 0.3 }, + roomGraphicsQuality: 'medium' as 'cheep' | 'low' | 'medium' | 'high' | 'ultra', + roomUseOrthographicCamera: true, + }; + + public static watchers = []; + + public static get<T extends keyof typeof ColdDeviceStorage.default>(key: T): typeof ColdDeviceStorage.default[T] { + // TODO: indexedDBにする + // ただしその際はnullチェックではなくキー存在チェックにしないとダメ + // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) + const value = localStorage.getItem(PREFIX + key); + if (value == null) { + return ColdDeviceStorage.default[key]; + } else { + return JSON.parse(value); + } + } + + public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + + for (const watcher of this.watchers) { + if (watcher.key === key) watcher.callback(value); + } + } + + public static watch(key, callback) { + this.watchers.push({ key, callback }); + } + + // TODO: VueのcustomRef使うと良い感じになるかも + public static ref<T extends keyof typeof ColdDeviceStorage.default>(key: T) { + const v = ColdDeviceStorage.get(key); + const r = ref(v); + // TODO: このままではwatcherがリークするので開放する方法を考える + this.watch(key, v => { + r.value = v; + }); + return r; + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue場で設定コントロールのmodelとして使う用 + */ + public static makeGetterSetter<K extends keyof typeof ColdDeviceStorage.default>(key: K) { + // TODO: VueのcustomRef使うと良い感じになるかも + const valueRef = ColdDeviceStorage.ref(key); + return { + get: () => { + return valueRef.value; + }, + set: (value: unknown) => { + const val = value; + ColdDeviceStorage.set(key, val); + } + }; + } +} + +// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $store: typeof defaultStore; + } +} diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss new file mode 100644 index 0000000000..951d5a14f3 --- /dev/null +++ b/packages/client/src/style.scss @@ -0,0 +1,562 @@ +@charset "utf-8"; + +:root { + --radius: 12px; + --marginFull: 16px; + --marginHalf: 10px; + + --margin: var(--marginFull); + + @media (max-width: 500px) { + --margin: var(--marginHalf); + } + + //--ad: rgb(255 169 0 / 10%); +} + +::selection { + color: #fff; + background-color: var(--accent); +} + +html { + touch-action: manipulation; + background-color: var(--bg); + background-attachment: fixed; + background-size: cover; + background-position: center; + color: var(--fg); + overflow: auto; + overflow-wrap: break-word; + font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + line-height: 1.35; + text-size-adjust: 100%; + tab-size: 2; + + &, * { + scrollbar-color: var(--scrollbarHandle) inherit; + scrollbar-width: thin; + + &:hover { + scrollbar-color: var(--scrollbarHandleHover) inherit; + } + + &:active { + scrollbar-color: var(--accent) inherit; + } + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: inherit; + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbarHandle); + + &:hover { + background: var(--scrollbarHandleHover); + } + + &:active { + background: var(--accent); + } + } + } + + &.f-small { + font-size: 0.9em; + } + + &.f-large { + font-size: 1.1em; + } + + &.f-veryLarge { + font-size: 1.2em; + } + + &.useSystemFont { + font-family: sans-serif; + } +} + +html._themeChanging_ { + &, * { + transition: background 1s ease, border 1s ease !important; + } +} + +html, body { + margin: 0; + padding: 0; + scroll-behavior: smooth; +} + +a { + text-decoration: none; + cursor: pointer; + color: inherit; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + + &:hover { + text-decoration: underline; + } +} + +textarea, input { + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; +} + +optgroup, option { + background: var(--panel); + color: var(--fg); +} + +hr { + margin: var(--margin) 0 var(--margin) 0; + border: none; + height: 1px; + background: var(--divider); +} + +._noSelect { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +._ghost { + &, * { + @extend ._noSelect; + pointer-events: none; + } +} + +._modalBg { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--modalBg); + -webkit-backdrop-filter: var(--modalBgFilter); + backdrop-filter: var(--modalBgFilter); +} + +._shadow { + box-shadow: 0px 4px 32px var(--shadow) !important; +} + +._button { + appearance: none; + display: inline-block; + padding: 0; + margin: 0; // for Safari + background: none; + border: none; + cursor: pointer; + color: inherit; + touch-action: manipulation; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + font-size: 1em; + font-family: inherit; + line-height: inherit; + + &, * { + @extend ._noSelect; + } + + * { + pointer-events: none; + } + + &:focus-visible { + outline: none; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +._buttonPrimary { + @extend ._button; + color: var(--fgOnAccent); + background: var(--accent); + + &:not(:disabled):hover { + background: var(--X8); + } + + &:not(:disabled):active { + background: var(--X9); + } +} + +._buttonGradate { + @extend ._buttonPrimary; + color: var(--fgOnAccent); + 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)); + } +} + +._help { + color: var(--accent); + cursor: help +} + +._textButton { + @extend ._button; + color: var(--accent); + + &:not(:disabled):hover { + text-decoration: underline; + } +} + +._inputs { + display: flex; + margin: 32px 0; + + &:first-child { + margin-top: 8px; + } + + &:last-child { + margin-bottom: 8px; + } + + > * { + flex: 1; + margin: 0 !important; + + &:not(:first-child) { + margin-left: 8px !important; + } + + &:not(:last-child) { + margin-right: 8px !important; + } + } +} + +._panel { + background: var(--panel); + border-radius: var(--radius); + overflow: clip; +} + +._block { + @extend ._panel; + + & + ._block { + margin-top: var(--margin); + } +} + +._gap { + margin: var(--margin) 0; +} + +// TODO: 廃止 +._card { + @extend ._panel; + + // TODO: _cardTitle に + > ._title { + margin: 0; + padding: 22px 32px; + font-size: 1em; + border-bottom: solid 1px var(--panelHeaderDivider); + font-weight: bold; + background: var(--panelHeaderBg); + color: var(--panelHeaderFg); + + @media (max-width: 500px) { + padding: 16px; + font-size: 1em; + } + } + + // TODO: _cardContent に + > ._content { + padding: 32px; + + @media (max-width: 500px) { + padding: 16px; + } + + &._noPad { + padding: 0 !important; + } + + & + ._content { + border-top: solid 0.5px var(--divider); + } + } + + // TODO: _cardFooter に + > ._footer { + border-top: solid 0.5px var(--divider); + padding: 24px 32px; + + @media (max-width: 500px) { + padding: 16px; + } + } +} + +._borderButton { + @extend ._button; + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border: solid 0.5px var(--divider); + border-radius: var(--radius); + + &:active { + border-color: var(--accent); + } +} + +._window { + background: var(--panel); + border-radius: var(--radius); + contain: content; +} + +._popup { + background: var(--popup); + border-radius: var(--radius); + contain: layout; // ふき出しがボックスから飛び出て表示されるようなデザインをする場合もあるので paint は contain することができない +} + +// TODO: 廃止 +._monolithic_ { + ._section:not(:empty) { + box-sizing: border-box; + padding: var(--root-margin, 32px); + + @media (max-width: 500px) { + --root-margin: 10px; + } + + & + ._section:not(:empty) { + border-top: solid 0.5px var(--divider); + } + } +} + +._narrow_ ._card { + > ._title { + padding: 16px; + font-size: 1em; + } + + > ._content { + padding: 16px; + } + + > ._footer { + padding: 16px; + } +} + +._acrylic { + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} + +._inputSplit { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + grid-gap: 8px; + margin: 1em 0; + + > * { + margin: 0 !important; + } +} + +._formBlock { + margin: 20px 0; +} + +._formRoot { + > ._formBlock:first-child { + margin-top: 0; + } + + > ._formBlock:last-child { + margin-bottom: 0; + } +} + +._table { + > ._row { + display: flex; + + &:not(:last-child) { + margin-bottom: 16px; + + @media (max-width: 500px) { + margin-bottom: 8px; + } + } + + > ._cell { + flex: 1; + + > ._label { + font-size: 80%; + opacity: 0.7; + + > ._icon { + margin-right: 4px; + display: none; + } + } + } + } +} + +._fullinfo { + padding: 64px 32px; + text-align: center; + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} + +._keyValue { + display: flex; + + > * { + flex: 1; + } +} + +._link { + color: var(--link); +} + +._caption { + font-size: 0.8em; + opacity: 0.7; +} + +._monospace { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important; +} + +._code { + @extend ._monospace; + background: #2d2d2d; + color: #ccc; + font-size: 14px; + line-height: 1.5; + padding: 5px; +} + +.prism-editor__textarea:focus { + outline: none; +} + +._zoom { + transition-duration: 0.5s, 0.5s; + transition-property: opacity, transform; + transition-timing-function: cubic-bezier(0,.5,.5,1); +} + +.zoom-enter-active, .zoom-leave-active { + transition: opacity 0.5s, transform 0.5s !important; +} +.zoom-enter-from, .zoom-leave-to { + opacity: 0; + transform: scale(0.9); +} + +@keyframes blink { + 0% { opacity: 1; transform: scale(1); } + 30% { opacity: 1; transform: scale(1); } + 90% { opacity: 0; transform: scale(0.5); } +} + +@keyframes tada { + from { + transform: scale3d(1, 1, 1); + } + + 10%, + 20% { + transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + } + + 30%, + 50%, + 70%, + 90% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + } + + 40%, + 60%, + 80% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + } + + to { + transform: scale3d(1, 1, 1); + } +} + +._anime_bounce { + will-change: transform; + animation: bounce ease 0.7s; + animation-iteration-count: 1; + transform-origin: 50% 50%; +} +._anime_bounce_ready { + will-change: transform; + transform: scaleX(0.90) scaleY(0.90) ; +} +._anime_bounce_standBy { + transition: transform 0.1s ease; +} + +@keyframes bounce{ + 0% { + transform: scaleX(0.90) scaleY(0.90) ; + } + 19% { + transform: scaleX(1.10) scaleY(1.10) ; + } + 48% { + transform: scaleX(0.95) scaleY(0.95) ; + } + 100% { + transform: scaleX(1.00) scaleY(1.00) ; + } +} diff --git a/packages/client/src/sw/compose-notification.ts b/packages/client/src/sw/compose-notification.ts new file mode 100644 index 0000000000..0aed9610ea --- /dev/null +++ b/packages/client/src/sw/compose-notification.ts @@ -0,0 +1,103 @@ +/** + * Notification composer of Service Worker + */ +declare var self: ServiceWorkerGlobalScope; + +import { getNoteSummary } from '@/scripts/get-note-summary'; +import * as misskey from 'misskey-js'; + +function getUserName(user: misskey.entities.User): string { + return user.name || user.username; +} + +export default async function(type, data, i18n): Promise<[string, NotificationOptions] | null | undefined> { + if (!i18n) { + console.log('no i18n'); + return; + } + + switch (type) { + case 'driveFileCreated': // TODO (Server Side) + return [i18n.t('_notification.fileUploaded'), { + body: data.name, + icon: data.url + }]; + case 'notification': + switch (data.type) { + case 'mention': + return [i18n.t('_notification.youGotMention', { name: getUserName(data.user) }), { + body: getNoteSummary(data.note, i18n.locale), + icon: data.user.avatarUrl + }]; + + case 'reply': + return [i18n.t('_notification.youGotReply', { name: getUserName(data.user) }), { + body: getNoteSummary(data.note, i18n.locale), + icon: data.user.avatarUrl + }]; + + case 'renote': + return [i18n.t('_notification.youRenoted', { name: getUserName(data.user) }), { + body: getNoteSummary(data.note, i18n.locale), + icon: data.user.avatarUrl + }]; + + case 'quote': + return [i18n.t('_notification.youGotQuote', { name: getUserName(data.user) }), { + body: getNoteSummary(data.note, i18n.locale), + icon: data.user.avatarUrl + }]; + + case 'reaction': + return [`${data.reaction} ${getUserName(data.user)}`, { + body: getNoteSummary(data.note, i18n.locale), + icon: data.user.avatarUrl + }]; + + case 'pollVote': + return [i18n.t('_notification.youGotPoll', { name: getUserName(data.user) }), { + body: getNoteSummary(data.note, i18n.locale), + icon: data.user.avatarUrl + }]; + + case 'follow': + return [i18n.t('_notification.youWereFollowed'), { + body: getUserName(data.user), + icon: data.user.avatarUrl + }]; + + case 'receiveFollowRequest': + return [i18n.t('_notification.youReceivedFollowRequest'), { + body: getUserName(data.user), + icon: data.user.avatarUrl + }]; + + case 'followRequestAccepted': + return [i18n.t('_notification.yourFollowRequestAccepted'), { + body: getUserName(data.user), + icon: data.user.avatarUrl + }]; + + case 'groupInvited': + return [i18n.t('_notification.youWereInvitedToGroup'), { + body: data.group.name + }]; + + default: + return null; + } + case 'unreadMessagingMessage': + if (data.groupId === null) { + return [i18n.t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.user) }), { + icon: data.user.avatarUrl, + tag: `messaging:user:${data.user.id}` + }]; + } + return [i18n.t('_notification.youGotMessagingMessageFromGroup', { name: data.group.name }), { + icon: data.user.avatarUrl, + tag: `messaging:group:${data.group.id}` + }]; + default: + return null; + } +} diff --git a/packages/client/src/sw/sw.ts b/packages/client/src/sw/sw.ts new file mode 100644 index 0000000000..68c650c771 --- /dev/null +++ b/packages/client/src/sw/sw.ts @@ -0,0 +1,123 @@ +/** + * Service Worker + */ +declare var self: ServiceWorkerGlobalScope; + +import { get, set } from 'idb-keyval'; +import composeNotification from '@/sw/compose-notification'; +import { I18n } from '@/scripts/i18n'; + +//#region Variables +const version = _VERSION_; +const cacheName = `mk-cache-${version}`; + +let lang: string; +let i18n: I18n<any>; +let pushesPool: any[] = []; +//#endregion + +//#region Startup +get('lang').then(async prelang => { + if (!prelang) return; + lang = prelang; + return fetchLocale(); +}); +//#endregion + +//#region Lifecycle: Install +self.addEventListener('install', ev => { + self.skipWaiting(); +}); +//#endregion + +//#region Lifecycle: Activate +self.addEventListener('activate', ev => { + ev.waitUntil( + caches.keys() + .then(cacheNames => Promise.all( + cacheNames + .filter((v) => v !== cacheName) + .map(name => caches.delete(name)) + )) + .then(() => self.clients.claim()) + ); +}); +//#endregion + +//#region When: Fetching +self.addEventListener('fetch', ev => { + // Nothing to do +}); +//#endregion + +//#region When: Caught Notification +self.addEventListener('push', ev => { + // クライアント取得 + ev.waitUntil(self.clients.matchAll({ + includeUncontrolled: true + }).then(async clients => { + // クライアントがあったらストリームに接続しているということなので通知しない + if (clients.length != 0) return; + + const { type, body } = ev.data?.json(); + + // localeを読み込めておらずi18nがundefinedだった場合はpushesPoolにためておく + if (!i18n) return pushesPool.push({ type, body }); + + const n = await composeNotification(type, body, i18n); + if (n) return self.registration.showNotification(...n); + })); +}); +//#endregion + +//#region When: Caught a message from the client +self.addEventListener('message', ev => { + switch(ev.data) { + case 'clear': + return; // TODO + default: + break; + } + + if (typeof ev.data === 'object') { + // E.g. '[object Array]' → 'array' + const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase(); + + if (otype === 'object') { + if (ev.data.msg === 'initialize') { + lang = ev.data.lang; + set('lang', lang); + fetchLocale(); + } + } + } +}); +//#endregion + +//#region Function: (Re)Load i18n instance +async function fetchLocale() { + //#region localeファイルの読み込み + // Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う + const localeUrl = `/assets/locales/${lang}.${version}.json`; + let localeRes = await caches.match(localeUrl); + + if (!localeRes) { + localeRes = await fetch(localeUrl); + const clone = localeRes?.clone(); + if (!clone?.clone().ok) return; + + caches.open(cacheName).then(cache => cache.put(localeUrl, clone)); + } + + i18n = new I18n(await localeRes.json()); + //#endregion + + //#region i18nをきちんと読み込んだ後にやりたい処理 + for (const { type, body } of pushesPool) { + const n = await composeNotification(type, body, i18n); + if (n) self.registration.showNotification(...n); + } + pushesPool = []; + //#endregion +} +//#endregion diff --git a/packages/client/src/symbols.ts b/packages/client/src/symbols.ts new file mode 100644 index 0000000000..6913f29c28 --- /dev/null +++ b/packages/client/src/symbols.ts @@ -0,0 +1 @@ +export const PAGE_INFO = Symbol('Page info'); diff --git a/packages/client/src/theme-store.ts b/packages/client/src/theme-store.ts new file mode 100644 index 0000000000..e7962e7e8e --- /dev/null +++ b/packages/client/src/theme-store.ts @@ -0,0 +1,34 @@ +import { api } from '@/os'; +import { $i } from '@/account'; +import { Theme } from './scripts/theme'; + +const lsCacheKey = $i ? `themes:${$i.id}` : ''; + +export function getThemes(): Theme[] { + return JSON.parse(localStorage.getItem(lsCacheKey) || '[]'); +} + +export async function fetchThemes(): Promise<void> { + if ($i == null) return; + + try { + const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); + } catch (e) { + if (e.code === 'NO_SUCH_KEY') return; + throw e; + } +} + +export async function addTheme(theme: Theme): Promise<void> { + await fetchThemes(); + const themes = getThemes().concat(theme); + await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); +} + +export async function removeTheme(theme: Theme): Promise<void> { + const themes = getThemes().filter(t => t.id != theme.id); + await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); +} diff --git a/packages/client/src/themes/_dark.json5 b/packages/client/src/themes/_dark.json5 new file mode 100644 index 0000000000..d8be16f60a --- /dev/null +++ b/packages/client/src/themes/_dark.json5 @@ -0,0 +1,90 @@ +// ダークテーマのベーステーマ +// このテーマが直接使われることは無い +{ + id: 'dark', + + name: 'Dark', + author: 'syuilo', + desc: 'Default dark theme', + kind: 'dark', + + props: { + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + accentedBg: ':alpha<0.15<@accent', + focus: ':alpha<0.3<@accent', + bg: '#000', + acrylicBg: ':alpha<0.5<@bg', + fg: '#dadada', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', + fgHighlighted: ':lighten<3<@fg', + fgOnAccent: '#fff', + divider: 'rgba(255, 255, 255, 0.1)', + indicator: '@accent', + panel: ':lighten<3<@bg', + panelHighlight: ':lighten<3<@panel', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + panelBorder: '" solid 1px var(--divider)', + acrylicPanel: ':alpha<0.5<@panel', + popup: ':lighten<3<@panel', + shadow: 'rgba(0, 0, 0, 0.3)', + header: ':alpha<0.7<@panel', + navBg: '@panel', + navFg: '@fg', + navHoverFg: ':lighten<17<@fg', + navActive: '@accent', + navIndicator: '@indicator', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + mentionMe: '@mention', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.5)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + dateLabelFg: '@fg', + infoBg: '#253142', + infoFg: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + switchBg: 'rgba(255, 255, 255, 0.15)', + cwBg: '#687390', + cwFg: '#393f4f', + cwHoverBg: '#707b97', + buttonBg: 'rgba(255, 255, 255, 0.05)', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + inputBorder: 'rgba(255, 255, 255, 0.1)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + badge: '#31b1ce', + messageBg: '@bg', + success: '#86b300', + error: '#ec4137', + warn: '#ecb637', + htmlThemeColor: '@bg', + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/client/src/themes/_light.json5 b/packages/client/src/themes/_light.json5 new file mode 100644 index 0000000000..251aa36c7a --- /dev/null +++ b/packages/client/src/themes/_light.json5 @@ -0,0 +1,90 @@ +// ライトテーマのベーステーマ +// このテーマが直接使われることは無い +{ + id: 'light', + + name: 'Light', + author: 'syuilo', + desc: 'Default light theme', + kind: 'light', + + props: { + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + accentedBg: ':alpha<0.15<@accent', + focus: ':alpha<0.3<@accent', + bg: '#fff', + acrylicBg: ':alpha<0.5<@bg', + fg: '#5f5f5f', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', + fgHighlighted: ':darken<3<@fg', + fgOnAccent: '#fff', + divider: 'rgba(0, 0, 0, 0.1)', + indicator: '@accent', + panel: ':lighten<3<@bg', + panelHighlight: ':darken<3<@panel', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + panelBorder: '" solid 1px var(--divider)', + acrylicPanel: ':alpha<0.5<@panel', + popup: ':lighten<3<@panel', + shadow: 'rgba(0, 0, 0, 0.1)', + header: ':alpha<0.7<@panel', + navBg: '@panel', + navFg: '@fg', + navHoverFg: ':darken<17<@fg', + navActive: '@accent', + navIndicator: '@indicator', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + mentionMe: '@mention', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.3)', + scrollbarHandle: 'rgba(0, 0, 0, 0.2)', + scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', + dateLabelFg: '@fg', + infoBg: '#e5f5ff', + infoFg: '#72818a', + infoWarnBg: '#fff0db', + infoWarnFg: '#8f6e31', + switchBg: 'rgba(0, 0, 0, 0.15)', + cwBg: '#b1b9c1', + cwFg: '#fff', + cwHoverBg: '#bbc4ce', + buttonBg: 'rgba(0, 0, 0, 0.05)', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + listItemHoverBg: 'rgba(0, 0, 0, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', + badge: '#31b1ce', + messageBg: '@bg', + success: '#86b300', + error: '#ec4137', + warn: '#ecb637', + htmlThemeColor: '@bg', + X2: ':darken<2<@panel', + X3: 'rgba(0, 0, 0, 0.05)', + X4: 'rgba(0, 0, 0, 0.1)', + X5: 'rgba(0, 0, 0, 0.05)', + X6: 'rgba(0, 0, 0, 0.25)', + X7: 'rgba(0, 0, 0, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.1)', + X12: 'rgba(0, 0, 0, 0.1)', + X13: 'rgba(0, 0, 0, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/client/src/themes/d-astro.json5 b/packages/client/src/themes/d-astro.json5 new file mode 100644 index 0000000000..c6a927ec3a --- /dev/null +++ b/packages/client/src/themes/d-astro.json5 @@ -0,0 +1,78 @@ +{ + id: '080a01c5-377d-4fbb-88cc-6bb5d04977ea', + base: 'dark', + name: 'Mi Astro Dark', + author: 'syuilo', + props: { + bg: '#232125', + fg: '#efdab9', + cwBg: '#687390', + cwFg: '#393f4f', + link: '#78b0a0', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: '#2a272b', + accent: '#81c08b', + header: ':alpha<0.7<@bg', + infoBg: '#253142', + infoFg: '#fff', + renote: '#659CC8', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(255, 255, 255, 0.1)', + hashtag: '#ff9156', + mention: '#ffd152', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: 'rgba(255, 255, 255, 0.05)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '#fb5d38', + messageBg: '@bg', + navActive: '@accent', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@accent', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<-20<@accent', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + }, +} diff --git a/packages/client/src/themes/d-black.json5 b/packages/client/src/themes/d-black.json5 new file mode 100644 index 0000000000..3c18ebdaf1 --- /dev/null +++ b/packages/client/src/themes/d-black.json5 @@ -0,0 +1,17 @@ +{ + id: '8c539dc1-0fab-4d47-9194-39c508e9bfe1', + + name: 'Mi Black', + author: 'syuilo', + + base: 'dark', + + props: { + divider: '#2d2d2d', + panel: '#131313', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + shadow: 'rgba(255, 255, 255, 0.05)', + modalBg: 'rgba(255, 255, 255, 0.1)', + }, +} diff --git a/packages/client/src/themes/d-botanical.json5 b/packages/client/src/themes/d-botanical.json5 new file mode 100644 index 0000000000..c03b95e2d7 --- /dev/null +++ b/packages/client/src/themes/d-botanical.json5 @@ -0,0 +1,26 @@ +{ + id: '504debaf-4912-6a4c-5059-1db08a76b737', + + name: 'Mi Botanical Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(148, 179, 0)', + bg: 'rgb(37, 38, 36)', + fg: 'rgb(216, 212, 199)', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: 'rgb(47, 47, 44)', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + header: ':alpha<0.7<@panel', + navBg: '#363636', + renote: '@accent', + mention: 'rgb(212, 153, 76)', + mentionMe: 'rgb(212, 210, 76)', + hashtag: '#5bcbb0', + link: '@accent', + }, +} diff --git a/packages/client/src/themes/d-dark.json5 b/packages/client/src/themes/d-dark.json5 new file mode 100644 index 0000000000..d24ce4df69 --- /dev/null +++ b/packages/client/src/themes/d-dark.json5 @@ -0,0 +1,26 @@ +{ + id: '8050783a-7f63-445a-b270-36d0f6ba1677', + + name: 'Mi Dark', + author: 'syuilo', + desc: 'Default light theme', + + base: 'dark', + + props: { + bg: '#232323', + fg: 'rgb(199, 209, 216)', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: '#2d2d2d', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + header: ':alpha<0.7<@panel', + navBg: '#363636', + renote: '@accent', + mention: '#da6d35', + mentionMe: '#d44c4c', + hashtag: '#4cb8d4', + link: '@accent', + }, +} diff --git a/packages/client/src/themes/d-future.json5 b/packages/client/src/themes/d-future.json5 new file mode 100644 index 0000000000..b6fa1ab0c1 --- /dev/null +++ b/packages/client/src/themes/d-future.json5 @@ -0,0 +1,27 @@ +{ + id: '32a637ef-b47a-4775-bb7b-bacbb823f865', + + name: 'Mi Future Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#63e2b7', + bg: '#101014', + fg: '#D5D5D6', + fgHighlighted: '#fff', + fgOnAccent: '#000', + divider: 'rgba(255, 255, 255, 0.1)', + panel: '#18181c', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + renote: '@accent', + mention: '#f2c97d', + mentionMe: '@accent', + hashtag: '#70c0e8', + link: '#e88080', + buttonGradateA: '@accent', + buttonGradateB: ':saturate<30<:hue<30<@accent', + }, +} diff --git a/packages/client/src/themes/d-persimmon.json5 b/packages/client/src/themes/d-persimmon.json5 new file mode 100644 index 0000000000..e36265ff10 --- /dev/null +++ b/packages/client/src/themes/d-persimmon.json5 @@ -0,0 +1,25 @@ +{ + id: 'c503d768-7c70-4db2-a4e6-08264304bc8d', + + name: 'Mi Persimmon Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(206, 102, 65)', + bg: 'rgb(31, 33, 31)', + fg: '#cdd8c7', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: 'rgb(41, 43, 41)', + infoFg: '@fg', + infoBg: '#333c3b', + navBg: '#141714', + renote: '@accent', + mention: '@accent', + mentionMe: '#de6161', + hashtag: '#68bad0', + link: '#a1c758', + }, +} diff --git a/packages/client/src/themes/d-pumpkin.json5 b/packages/client/src/themes/d-pumpkin.json5 new file mode 100644 index 0000000000..064ca4577b --- /dev/null +++ b/packages/client/src/themes/d-pumpkin.json5 @@ -0,0 +1,88 @@ +{ + id: '0b64fef3-02c7-20b5-dd87-b3f77e2b4301', + + name: 'Mi Pumpkin Dark', + author: 'syuilo', + + base: 'dark', + + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: 'rgb(37, 32, 47)', + fg: '#e0d5c0', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + cwBg: '#687390', + cwFg: '#393f4f', + link: 'rgb(172, 193, 68)', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: 'rgb(242, 133, 36)', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: 'rgb(110, 179, 72)', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(255, 255, 255, 0.1)', + hashtag: 'rgb(188, 90, 255)', + mention: 'rgb(72, 179, 139)', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: 'rgba(255, 255, 255, 0.05)', + switchBg: 'rgba(255, 255, 255, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '@accent', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + fgOnAccent: '#000', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + }, +} diff --git a/packages/client/src/themes/l-apricot.json5 b/packages/client/src/themes/l-apricot.json5 new file mode 100644 index 0000000000..1ed5525575 --- /dev/null +++ b/packages/client/src/themes/l-apricot.json5 @@ -0,0 +1,22 @@ +{ + id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b', + + name: 'Mi Apricot Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: 'rgb(234, 154, 82)', + bg: '#e6e5e2', + fg: 'rgb(149, 143, 139)', + panel: '#EEECE8', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + infoBg: 'rgb(226, 235, 241)', + }, +} diff --git a/packages/client/src/themes/l-light.json5 b/packages/client/src/themes/l-light.json5 new file mode 100644 index 0000000000..248355c945 --- /dev/null +++ b/packages/client/src/themes/l-light.json5 @@ -0,0 +1,20 @@ +{ + id: '4eea646f-7afa-4645-83e9-83af0333cd37', + + name: 'Mi Light', + author: 'syuilo', + desc: 'Default light theme', + + base: 'light', + + props: { + bg: '#f9f9f9', + fg: '#676767', + divider: '#e8e8e8', + header: ':alpha<0.7<@panel', + navBg: '#fff', + panel: '#fff', + panelHeaderDivider: '@divider', + mentionMe: 'rgb(0, 179, 70)', + }, +} diff --git a/packages/client/src/themes/l-rainy.json5 b/packages/client/src/themes/l-rainy.json5 new file mode 100644 index 0000000000..283dd74c6c --- /dev/null +++ b/packages/client/src/themes/l-rainy.json5 @@ -0,0 +1,21 @@ +{ + id: 'a58a0abb-ff8c-476a-8dec-0ad7837e7e96', + + name: 'Mi Rainy Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#5db0da', + bg: 'rgb(246 248 249)', + fg: '#636b71', + panel: '#fff', + divider: 'rgb(230 233 234)', + panelHeaderDivider: '@divider', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + }, +} diff --git a/packages/client/src/themes/l-sushi.json5 b/packages/client/src/themes/l-sushi.json5 new file mode 100644 index 0000000000..5846927d65 --- /dev/null +++ b/packages/client/src/themes/l-sushi.json5 @@ -0,0 +1,18 @@ +{ + id: '213273e5-7d20-d5f0-6e36-1b6a4f67115c', + + name: 'Mi Sushi Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#e36749', + bg: '#f0eee9', + fg: '#5f5f5f', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '#229e82', + }, +} diff --git a/packages/client/src/themes/l-vivid.json5 b/packages/client/src/themes/l-vivid.json5 new file mode 100644 index 0000000000..b3c08f38ae --- /dev/null +++ b/packages/client/src/themes/l-vivid.json5 @@ -0,0 +1,82 @@ +{ + id: '6128c2a9-5c54-43fe-a47d-17942356470b', + + name: 'Mi Vivid Light', + author: 'syuilo', + + base: 'light', + + props: { + bg: '#fafafa', + fg: '#444', + cwBg: '#b1b9c1', + cwFg: '#fff', + link: '#ff9400', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: '#fff', + accent: '#008cff', + header: ':alpha<0.7<@panel', + infoBg: '#e5f5ff', + infoFg: '#72818a', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.1)', + divider: 'rgba(0, 0, 0, 0.08)', + hashtag: '#92d400', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.3)', + success: '#86b300', + buttonBg: 'rgba(0, 0, 0, 0.05)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#bbc4ce', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + infoWarnBg: '#fff0db', + infoWarnFg: '#8f6e31', + navHoverFg: ':darken<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@accent', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':darken<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + htmlThemeColor: '@bg', + panelHighlight: ':darken<3<@panel', + listItemHoverBg: 'rgba(0, 0, 0, 0.03)', + scrollbarHandle: 'rgba(0, 0, 0, 0.2)', + wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: '@divider', + scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', + X2: ':darken<2<@panel', + X3: 'rgba(0, 0, 0, 0.05)', + X4: 'rgba(0, 0, 0, 0.1)', + X5: 'rgba(0, 0, 0, 0.05)', + X6: 'rgba(0, 0, 0, 0.25)', + X7: 'rgba(0, 0, 0, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.1)', + X12: 'rgba(0, 0, 0, 0.1)', + X13: 'rgba(0, 0, 0, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/client/src/ui/_common_/common.vue b/packages/client/src/ui/_common_/common.vue new file mode 100644 index 0000000000..59e26c837e --- /dev/null +++ b/packages/client/src/ui/_common_/common.vue @@ -0,0 +1,89 @@ +<template> +<component v-for="popup in popups" + :key="popup.id" + :is="popup.component" + v-bind="popup.props" + v-on="popup.events" +/> + +<XUpload v-if="uploads.length > 0"/> + +<XStreamIndicator/> + +<div id="wait" v-if="pendingApiRequestsCount > 0"></div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { stream, popup, popups, uploads, pendingApiRequestsCount } from '@/os'; +import * as sound from '@/scripts/sound'; +import { $i } from '@/account'; + +export default defineComponent({ + components: { + XStreamIndicator: defineAsyncComponent(() => import('./stream-indicator.vue')), + XUpload: defineAsyncComponent(() => import('./upload.vue')), + }, + + setup() { + const onNotification = notification => { + if ($i.mutingNotificationTypes.includes(notification.type)) return; + + if (document.visibilityState === 'visible') { + stream.send('readNotification', { + id: notification.id + }); + + popup(import('@/components/toast.vue'), { + notification + }, {}, 'closed'); + } + + sound.play('notification'); + }; + + if ($i) { + const connection = stream.useChannel('main', null, 'UI'); + connection.on('notification', onNotification); + } + + return { + uploads, + popups, + pendingApiRequestsCount, + }; + }, +}); +</script> + +<style lang="scss"> +#wait { + display: block; + position: fixed; + z-index: 10000; + top: 15px; + right: 15px; + + &:before { + content: ""; + display: block; + width: 18px; + height: 18px; + box-sizing: border-box; + border: solid 2px transparent; + border-top-color: var(--accent); + border-left-color: var(--accent); + border-radius: 50%; + animation: progress-spinner 400ms linear infinite; + } +} + +@keyframes progress-spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} +</style> diff --git a/packages/client/src/ui/_common_/sidebar.vue b/packages/client/src/ui/_common_/sidebar.vue new file mode 100644 index 0000000000..9bbf1b3e3d --- /dev/null +++ b/packages/client/src/ui/_common_/sidebar.vue @@ -0,0 +1,388 @@ +<template> +<div class="mvcprjjd"> + <transition name="nav-back"> + <div class="nav-back _modalBg" + v-if="showing" + @click="showing = false" + @touchstart.passive="showing = false" + ></div> + </transition> + + <transition name="nav"> + <nav class="nav" :class="{ iconOnly, hidden }" v-show="showing"> + <div> + <button class="item _button account" @click="openAccountMenu" v-click-anime> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + <MkA class="item index" active-class="active" to="/" exact v-click-anime> + <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime> + <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> + <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" v-click-anime> + <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + </MkA> + <button class="item _button" @click="more" v-click-anime> + <i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> + <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> + </button> + <MkA class="item" active-class="active" to="/settings" v-click-anime> + <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> + </MkA> + <button class="item _button post" @click="post" data-cy-open-post-form> + <i class="fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> + </button> + </div> + </nav> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { openAccountMenu } from '@/account'; + +export default defineComponent({ + props: { + defaultHidden: { + type: Boolean, + required: false, + default: false, + } + }, + + data() { + return { + host: host, + showing: false, + accounts: [], + connection: null, + menuDef: menuDef, + iconOnly: false, + hidden: this.defaultHidden, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + + otherNavItemIndicated(): boolean { + for (const def in this.menuDef) { + if (this.menu.includes(def)) continue; + if (this.menuDef[def].indicated) return true; + } + return false; + }, + }, + + watch: { + $route(to, from) { + this.showing = false; + }, + + '$store.reactiveState.menuDisplay.value'() { + this.calcViewState(); + }, + + iconOnly() { + this.$nextTick(() => { + this.$emit('change-view-mode'); + }); + }, + + hidden() { + this.$nextTick(() => { + this.$emit('change-view-mode'); + }); + } + }, + + created() { + window.addEventListener('resize', this.calcViewState); + this.calcViewState(); + }, + + methods: { + calcViewState() { + this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.menuDisplay === 'sideIcon'); + if (!this.defaultHidden) { + this.hidden = (window.innerWidth <= 650); + } + }, + + show() { + this.showing = true; + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + more(ev) { + os.popup(import('@/components/launch-pad.vue'), {}, { + }, 'closed'); + }, + + openAccountMenu, + } +}); +</script> + +<style lang="scss" scoped> +.nav-enter-active, +.nav-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.nav-enter-from, +.nav-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.nav-back-enter-active, +.nav-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.nav-back-enter-from, +.nav-back-leave-active { + opacity: 0; +} + +.mvcprjjd { + $ui-font-size: 1em; // TODO: どこかに集約したい + $nav-width: 250px; + $nav-icon-only-width: 86px; + + > .nav-back { + z-index: 1001; + } + + > .nav { + $avatar-size: 32px; + $avatar-margin: 8px; + + flex: 0 0 $nav-width; + width: $nav-width; + box-sizing: border-box; + + &.iconOnly { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width; + + &:not(.hidden) { + > div { + width: $nav-icon-only-width; + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + } + + > .item { + padding-left: 0; + padding: 18px 0; + width: 100%; + text-align: center; + font-size: $ui-font-size * 1.1; + line-height: initial; + + > i, + > .avatar { + display: block; + margin: 0 auto; + } + + > i { + opacity: 0.7; + } + + > .text { + display: none; + } + + &:hover, &.active { + > i, > .text { + opacity: 1; + } + } + + &:first-child { + margin-bottom: 8px; + } + + &:last-child { + margin-top: 8px; + } + } + } + } + } + + &.hidden { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + } + + &:not(.hidden) { + display: block !important; + } + + > div { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + width: $nav-width; + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + overflow: auto; + overflow-x: clip; + background: var(--navBg); + + > .divider { + margin: 16px 16px; + border-top: solid 0.5px var(--divider); + } + + > .item { + position: relative; + display: block; + padding-left: 24px; + font-size: $ui-font-size; + line-height: 2.85rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + color: var(--navFg); + + > i { + position: relative; + width: 32px; + } + + > i, + > .avatar { + margin-right: $avatar-margin; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > .indicator { + position: absolute; + top: 0; + left: 20px; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + > .text { + position: relative; + font-size: 0.9em; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + + &:hover, &.active { + &:before { + content: ""; + display: block; + width: calc(100% - 24px); + height: 100%; + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: var(--accentedBg); + } + } + + &:first-child, &:last-child { + position: sticky; + z-index: 1; + padding-top: 8px; + padding-bottom: 8px; + background: var(--X14); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + } + + &:first-child { + top: 0; + + &:hover, &.active { + &:before { + content: none; + } + } + } + + &:last-child { + bottom: 0; + color: var(--fgOnAccent); + + &:before { + content: ""; + display: block; + width: calc(100% - 20px); + height: calc(100% - 20px); + margin: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 999px; + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + } + + &:hover, &.active { + &:before { + background: var(--accentLighten); + } + } + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/stream-indicator.vue b/packages/client/src/ui/_common_/stream-indicator.vue new file mode 100644 index 0000000000..8b1b4b567c --- /dev/null +++ b/packages/client/src/ui/_common_/stream-indicator.vue @@ -0,0 +1,70 @@ +<template> +<div class="nsbbhtug" v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" @click="resetDisconnected"> + <div>{{ $ts.disconnectedFromServer }}</div> + <div class="command"> + <button class="_textButton" @click="reload">{{ $ts.reload }}</button> + <button class="_textButton">{{ $ts.doNothing }}</button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + data() { + return { + hasDisconnected: false, + } + }, + computed: { + stream() { + return os.stream; + }, + }, + created() { + os.stream.on('_disconnected_', this.onDisconnected); + }, + beforeUnmount() { + os.stream.off('_disconnected_', this.onDisconnected); + }, + methods: { + onDisconnected() { + this.hasDisconnected = true; + }, + resetDisconnected() { + this.hasDisconnected = false; + }, + reload() { + location.reload(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.nsbbhtug { + position: fixed; + z-index: 16385; + bottom: 8px; + right: 8px; + margin: 0; + padding: 6px 12px; + font-size: 0.9em; + color: #fff; + background: #000; + opacity: 0.8; + border-radius: 4px; + max-width: 320px; + + > .command { + display: flex; + justify-content: space-around; + + > button { + padding: 0.7em; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/upload.vue b/packages/client/src/ui/_common_/upload.vue new file mode 100644 index 0000000000..0ca353e4e1 --- /dev/null +++ b/packages/client/src/ui/_common_/upload.vue @@ -0,0 +1,134 @@ +<template> +<div class="mk-uploader _acrylic"> + <ol v-if="uploads.length > 0"> + <li v-for="ctx in uploads" :key="ctx.id"> + <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> + <div class="top"> + <p class="name"><i class="fas fa-spinner fa-pulse"></i>{{ ctx.name }}</p> + <p class="status"> + <span class="initing" v-if="ctx.progressValue === undefined">{{ $ts.waiting }}<MkEllipsis/></span> + <span class="kb" v-if="ctx.progressValue !== undefined">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> + <span class="percentage" v-if="ctx.progressValue !== undefined">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> + </p> + </div> + <progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress> + </li> + </ol> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + data() { + return { + uploads: os.uploads, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.mk-uploader { + position: fixed; + z-index: 10000; + right: 16px; + width: 260px; + top: 32px; + padding: 16px 20px; + pointer-events: none; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + border-radius: 8px; +} +.mk-uploader:empty { + display: none; +} +.mk-uploader > ol { + display: block; + margin: 0; + padding: 0; + list-style: none; +} +.mk-uploader > ol > li { + display: grid; + margin: 8px 0 0 0; + padding: 0; + height: 36px; + width: 100%; + border-top: solid 8px transparent; + grid-template-columns: 36px calc(100% - 44px); + grid-template-rows: 1fr 8px; + column-gap: 8px; + box-sizing: content-box; +} +.mk-uploader > ol > li:first-child { + margin: 0; + box-shadow: none; + border-top: none; +} +.mk-uploader > ol > li > .img { + display: block; + background-size: cover; + background-position: center center; + grid-column: 1/2; + grid-row: 1/3; +} +.mk-uploader > ol > li > .top { + display: flex; + grid-column: 2/3; + grid-row: 1/2; +} +.mk-uploader > ol > li > .top > .name { + display: block; + padding: 0 8px 0 0; + margin: 0; + font-size: 0.8em; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex-shrink: 1; +} +.mk-uploader > ol > li > .top > .name > i { + margin-right: 4px; +} +.mk-uploader > ol > li > .top > .status { + display: block; + margin: 0 0 0 auto; + padding: 0; + font-size: 0.8em; + flex-shrink: 0; +} +.mk-uploader > ol > li > .top > .status > .initing { +} +.mk-uploader > ol > li > .top > .status > .kb { +} +.mk-uploader > ol > li > .top > .status > .percentage { + display: inline-block; + width: 48px; + text-align: right; +} +.mk-uploader > ol > li > .top > .status > .percentage:after { + content: '%'; +} +.mk-uploader > ol > li > progress { + display: block; + background: transparent; + border: none; + border-radius: 4px; + overflow: hidden; + grid-column: 2/3; + grid-row: 2/3; + z-index: 2; + width: 100%; + height: 8px; +} +.mk-uploader > ol > li > progress::-webkit-progress-value { + background: var(--accent); +} +.mk-uploader > ol > li > progress::-webkit-progress-bar { + //background: var(--accentAlpha01); + background: transparent; +} +</style> diff --git a/packages/client/src/ui/chat/date-separated-list.vue b/packages/client/src/ui/chat/date-separated-list.vue new file mode 100644 index 0000000000..b21e425aba --- /dev/null +++ b/packages/client/src/ui/chat/date-separated-list.vue @@ -0,0 +1,163 @@ +<script lang="ts"> +import { defineComponent, h, PropType, TransitionGroup } from 'vue'; +import MkAd from '@/components/global/ad.vue'; + +export default defineComponent({ + props: { + items: { + type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, + required: true, + }, + reversed: { + type: Boolean, + required: false, + default: false + }, + ad: { + type: Boolean, + required: false, + default: false + }, + }, + + methods: { + focus() { + this.$slots.default[0].elm.focus(); + } + }, + + render() { + const 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() + }); + } + + return h(this.reversed ? 'div' : TransitionGroup, { + class: 'hmjzthxl', + name: this.reversed ? 'list-reversed' : 'list', + tag: 'div', + }, 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', + }), + getDateText(item.createdAt) + ]), + h('span', [ + 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; + } + } + })); + }, +}); +</script> + +<style lang="scss"> +.hmjzthxl { + > .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); + } + > .list-enter-from { + opacity: 0; + transform: translateY(-64px); + } + + > .list-reversed-enter-active, > .list-reversed-leave-active { + transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); + } + > .list-reversed-enter-from { + opacity: 0; + transform: translateY(64px); + } +} +</style> + +<style lang="scss"> +.hmjzthxl { + > .separator { + text-align: center; + position: relative; + + &:before { + content: ""; + display: block; + position: absolute; + top: 50%; + left: 0; + right: 0; + margin: auto; + width: calc(100% - 32px); + height: 1px; + background: var(--divider); + } + + > .date { + display: inline-block; + position: relative; + margin: 0; + padding: 0 16px; + line-height: 32px; + text-align: center; + font-size: 12px; + color: var(--dateLabelFg); + background: var(--panel); + + > span { + &:first-child { + margin-right: 8px; + + > .icon { + margin-right: 8px; + } + } + + &:last-child { + margin-left: 8px; + + > .icon { + margin-left: 8px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/header-clock.vue b/packages/client/src/ui/chat/header-clock.vue new file mode 100644 index 0000000000..3488289c21 --- /dev/null +++ b/packages/client/src/ui/chat/header-clock.vue @@ -0,0 +1,62 @@ +<template> +<div class="acemodlh _monospace"> + <div> + <span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span> + </div> + <div> + <span v-text="hh"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="mm"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="ss"></span> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + data() { + return { + clock: null, + y: null, + m: null, + d: null, + hh: null, + mm: null, + ss: null, + showColon: true, + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + tick() { + const now = new Date(); + this.y = now.getFullYear().toString(); + this.m = (now.getMonth() + 1).toString().padStart(2, '0'); + this.d = now.getDate().toString().padStart(2, '0'); + this.hh = now.getHours().toString().padStart(2, '0'); + this.mm = now.getMinutes().toString().padStart(2, '0'); + this.ss = now.getSeconds().toString().padStart(2, '0'); + this.showColon = now.getSeconds() % 2 === 0; + } + } +}); +</script> + +<style lang="scss" scoped> +.acemodlh { + opacity: 0.7; + font-size: 0.85em; + line-height: 1em; + text-align: center; +} +</style> diff --git a/packages/client/src/ui/chat/index.vue b/packages/client/src/ui/chat/index.vue new file mode 100644 index 0000000000..e8d15b2cfc --- /dev/null +++ b/packages/client/src/ui/chat/index.vue @@ -0,0 +1,467 @@ +<template> +<div class="mk-app" @contextmenu.self.prevent="onContextmenu"> + <XSidebar ref="menu" class="menu" :default-hidden="true"/> + + <div class="nav"> + <header class="header"> + <div class="left"> + <button class="_button account" @click="openAccountMenu"> + <MkAvatar :user="$i" class="avatar"/><!--<MkAcct class="text" :user="$i"/>--> + </button> + </div> + <div class="right"> + <MkA class="item" to="/my/messaging" v-tooltip="$ts.messaging"><i class="fas fa-comments icon"></i><span v-if="$i.hasUnreadMessagingMessage" class="indicator"><i class="fas fa-circle"></i></span></MkA> + <MkA class="item" to="/my/messages" v-tooltip="$ts.directNotes"><i class="fas fa-envelope icon"></i><span v-if="$i.hasUnreadSpecifiedNotes" class="indicator"><i class="fas fa-circle"></i></span></MkA> + <MkA class="item" to="/my/mentions" v-tooltip="$ts.mentions"><i class="fas fa-at icon"></i><span v-if="$i.hasUnreadMentions" class="indicator"><i class="fas fa-circle"></i></span></MkA> + <MkA class="item" to="/my/notifications" v-tooltip="$ts.notifications"><i class="fas fa-bell icon"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></MkA> + </div> + </header> + <div class="body"> + <div class="container"> + <div class="header">{{ $ts.timeline }}</div> + <div class="body"> + <MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><i class="fas fa-home icon"></i>{{ $ts._timelines.home }}</MkA> + <MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><i class="fas fa-comments icon"></i>{{ $ts._timelines.local }}</MkA> + <MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><i class="fas fa-share-alt icon"></i>{{ $ts._timelines.social }}</MkA> + <MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><i class="fas fa-globe icon"></i>{{ $ts._timelines.global }}</MkA> + </div> + </div> + <div class="container" v-if="followedChannels"> + <div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div> + <div class="body"> + <MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA> + </div> + </div> + <div class="container" v-if="featuredChannels"> + <div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div> + <div class="body"> + <MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA> + </div> + </div> + <div class="container" v-if="lists"> + <div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><i class="fas fa-plus"></i></button></div> + <div class="body"> + <MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><i class="fas fa-list-ul icon"></i>{{ list.name }}</MkA> + </div> + </div> + <div class="container" v-if="antennas"> + <div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><i class="fas fa-plus"></i></button></div> + <div class="body"> + <MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><i class="fas fa-satellite icon"></i>{{ antenna.name }}</MkA> + </div> + </div> + <div class="container"> + <div class="body"> + <MkA to="/my/favorites" class="item"><i class="fas fa-star icon"></i>{{ $ts.favorites }}</MkA> + </div> + </div> + <MkAd class="a" :prefer="['square']"/> + </div> + <footer class="footer"> + <div class="left"> + <button class="_button menu" @click="showMenu"> + <i class="fas fa-bars icon"></i> + </button> + </div> + <div class="right"> + <button class="_button item search" @click="search" v-tooltip="$ts.search"> + <i class="fas fa-search icon"></i> + </button> + <MkA class="item" to="/settings" v-tooltip="$ts.settings"><i class="fas fa-cog icon"></i></MkA> + </div> + </footer> + </div> + + <main class="main" @contextmenu.stop="onContextmenu"> + <header class="header"> + <MkHeader class="header" :info="pageInfo" :menu="menu" :center="false" @click="onHeaderClick"/> + </header> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage" class="body"/> + </keep-alive> + </transition> + </router-view> + </main> + + <XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/> + <div class="side widgets" :class="{ sideViewOpening }"> + <XWidgets/> + </div> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { instanceName, url } from '@/config'; +import XSidebar from '@/ui/_common_/sidebar.vue'; +import XWidgets from './widgets.vue'; +import XCommon from '../_common_/common.vue'; +import XSide from './side.vue'; +import XHeaderClock from './header-clock.vue'; +import * as os from '@/os'; +import { router } from '@/router'; +import { menuDef } from '@/menu'; +import { search } from '@/scripts/search'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { store } from './store'; +import * as symbols from '@/symbols'; +import { openAccountMenu } from '@/account'; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + XWidgets, + XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる + XHeaderClock, + }, + + provide() { + return { + sideViewHook: (path) => { + this.$refs.side.navigate(path); + } + }; + }, + + data() { + return { + pageInfo: null, + lists: null, + antennas: null, + followedChannels: null, + featuredChannels: null, + currentChannel: null, + menuDef: menuDef, + sideViewOpening: false, + instanceName, + }; + }, + + computed: { + menu() { + return [{ + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(this.$route.path); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.$route.path); + } + }]; + } + }, + + created() { + if (window.innerWidth < 1024) { + localStorage.setItem('ui', 'default'); + location.reload(); + } + + os.api('users/lists/list').then(lists => { + this.lists = lists; + }); + + os.api('antennas/list').then(antennas => { + this.antennas = antennas; + }); + + os.api('channels/followed', { limit: 20 }).then(channels => { + this.followedChannels = channels; + }); + + // TODO: pagination + os.api('channels/featured', { limit: 20 }).then(channels => { + this.featuredChannels = channels; + }); + }, + + methods: { + changePage(page) { + console.log(page); + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + document.title = `${this.pageInfo.title} | ${instanceName}`; + } + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + showMenu() { + this.$refs.menu.show(); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + back() { + history.back(); + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + onHeaderClick() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + 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 (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (window.getSelection().toString() !== '') return; + const path = this.$route.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(path); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(path); + } + }], e); + }, + + openAccountMenu, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + $header-height: 54px; // TODO: どこかに集約したい + $ui-font-size: 1em; // TODO: どこかに集約したい + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + display: flex; + + > .nav { + display: flex; + flex-direction: column; + width: 250px; + height: 100vh; + border-right: solid 4px var(--divider); + + > .header, > .footer { + $padding: 8px; + display: flex; + align-items: center; + z-index: 1000; + height: $header-height; + padding: $padding; + box-sizing: border-box; + user-select: none; + + &.header { + border-bottom: solid 0.5px var(--divider); + } + + &.footer { + border-top: solid 0.5px var(--divider); + } + + > .left, > .right { + > .item, > .menu { + display: inline-flex; + vertical-align: middle; + height: ($header-height - ($padding * 2)); + width: ($header-height - ($padding * 2)); + box-sizing: border-box; + //opacity: 0.6; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + > .icon { + margin: auto; + } + + > .indicator { + position: absolute; + top: 8px; + right: 8px; + color: var(--indicator); + font-size: 8px; + line-height: 8px; + animation: blink 1s infinite; + } + } + } + + > .left { + flex: 1; + min-width: 0; + + > .account { + display: flex; + align-items: center; + padding: 0 8px; + + > .avatar { + width: 26px; + height: 26px; + margin-right: 8px; + } + + > .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9em; + } + } + } + + > .right { + margin-left: auto; + } + } + + > .body { + flex: 1; + min-width: 0; + overflow: auto; + + > .container { + margin-top: 8px; + margin-bottom: 8px; + + & + .container { + margin-top: 16px; + } + + > .header { + display: flex; + font-size: 0.9em; + padding: 8px 16px; + position: sticky; + top: 0; + background: var(--X17); + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + z-index: 1; + color: var(--fgTransparentWeak); + + > .add { + margin-left: auto; + color: var(--fgTransparentWeak); + + &:hover { + color: var(--fg); + } + } + } + + > .body { + padding: 0 8px; + + > .item { + display: block; + padding: 6px 8px; + border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:hover { + text-decoration: none; + background: rgba(0, 0, 0, 0.05); + } + + &.active, &.active:hover { + background: var(--accent); + color: #fff !important; + } + + &.read { + color: var(--fgTransparent); + } + + > .icon { + margin-right: 8px; + opacity: 0.6; + } + } + } + } + + > .a { + margin: 12px; + } + } + } + + > .main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + height: 100vh; + position: relative; + background: var(--panel); + + > .header { + z-index: 1000; + height: $header-height; + background-color: var(--panel); + border-bottom: solid 0.5px var(--divider); + user-select: none; + } + + > .body { + width: 100%; + box-sizing: border-box; + overflow: auto; + } + } + + > .side { + width: 350px; + border-left: solid 4px var(--divider); + background: var(--panel); + + &.widgets.sideViewOpening { + @media (max-width: 1400px) { + display: none; + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/note-header.vue b/packages/client/src/ui/chat/note-header.vue new file mode 100644 index 0000000000..8ab03501b2 --- /dev/null +++ b/packages/client/src/ui/chat/note-header.vue @@ -0,0 +1,112 @@ +<template> +<header class="dehvdgxo"> + <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> + <MkUserName :user="note.user"/> + </MkA> + <span class="is-bot" v-if="note.user.isBot">bot</span> + <span class="username"><MkAcct :user="note.user"/></span> + <span class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></span> + <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></span> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span> + <MkA class="created-at" :to="notePage(note)"> + <MkTime :time="note.createdAt"/> + </MkA> + <span class="visibility" v-if="note.visibility !== 'public'"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> + </span> + <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> + </div> +</header> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import notePage from '@/filters/note'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + props: { + note: { + type: Object, + required: true + }, + }, + + data() { + return { + }; + }, + + methods: { + notePage, + userPage + } +}); +</script> + +<style lang="scss" scoped> +.dehvdgxo { + display: flex; + align-items: baseline; + white-space: nowrap; + font-size: 0.9em; + + > .name { + 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 { + margin-right: 0.5em; + color: var(--badge); + } + + > .username { + margin: 0 .5em 0 0; + overflow: hidden; + text-overflow: ellipsis; + } + + > .info { + font-size: 0.9em; + opacity: 0.7; + + > .mobile { + margin-right: 8px; + } + + > .visibility { + margin-left: 8px; + } + + > .localOnly { + margin-left: 8px; + } + } +} +</style> diff --git a/packages/client/src/ui/chat/note-preview.vue b/packages/client/src/ui/chat/note-preview.vue new file mode 100644 index 0000000000..2a08a3d7f5 --- /dev/null +++ b/packages/client/src/ui/chat/note-preview.vue @@ -0,0 +1,112 @@ +<template> +<div class="hduudsxk"> + <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 '@/components/cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + } + }, + + data() { + return { + showContent: false + }; + } +}); +</script> + +<style lang="scss" scoped> +.hduudsxk { + display: flex; + margin: 0; + padding: 0; + overflow: hidden; + font-size: 0.95em; + + > .avatar { + + @media (min-width: 350px) { + margin: 0 10px 0 0; + width: 44px; + height: 44px; + } + + @media (min-width: 500px) { + margin: 0 12px 0 0; + width: 48px; + height: 48px; + } + } + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 10px 0 0; + width: 40px; + height: 40px; + border-radius: 8px; + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + cursor: default; + margin: 0; + padding: 0; + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/note.sub.vue b/packages/client/src/ui/chat/note.sub.vue new file mode 100644 index 0000000000..75d9d98088 --- /dev/null +++ b/packages/client/src/ui/chat/note.sub.vue @@ -0,0 +1,137 @@ +<template> +<div class="wrpstxzv" :class="{ children }"> + <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 '@/components/cw-button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + name: 'XSub', + + components: { + XNoteHeader, + XSubNoteContent, + XCwButton, + }, + + props: { + note: { + type: Object, + required: true + }, + detail: { + type: Boolean, + required: false, + default: false + }, + children: { + type: Boolean, + required: false, + default: false + }, + // TODO + truncate: { + type: Boolean, + default: true + } + }, + + data() { + return { + showContent: false, + replies: [], + }; + }, + + created() { + if (this.detail) { + os.api('notes/children', { + noteId: this.note.id, + limit: 5 + }).then(replies => { + this.replies = replies; + }); + } + }, +}); +</script> + +<style lang="scss" scoped> +.wrpstxzv { + padding: 16px 16px; + font-size: 0.8em; + + &.children { + padding: 10px 0 0 16px; + font-size: 1em; + } + + > .main { + display: flex; + + > .avatar { + flex-shrink: 0; + display: block; + margin: 0 8px 0 0; + width: 36px; + height: 36px; + } + + > .body { + flex: 1; + min-width: 0; + + > .header { + margin-bottom: 2px; + } + + > .body { + > .cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; + + > .text { + margin-right: 8px; + } + } + + > .content { + > .text { + margin: 0; + padding: 0; + } + } + } + } + } + + > .reply { + border-left: solid 0.5px var(--divider); + margin-top: 10px; + } +} +</style> diff --git a/packages/client/src/ui/chat/note.vue b/packages/client/src/ui/chat/note.vue new file mode 100644 index 0000000000..c0b5cebd94 --- /dev/null +++ b/packages/client/src/ui/chat/note.vue @@ -0,0 +1,1144 @@ +<template> +<div + class="vfzoeqcg" + v-if="!muted" + v-show="!isDeleted" + :tabindex="!isDeleted ? '-1' : null" + :class="{ renote: isRenote, highlighted: appearNote._prId_ || appearNote._featuredId_, operating }" + v-hotkey="keymap" +> + <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> + <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> + <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> + <footer class="footer _panel"> + <button @click="reply()" class="button _button" v-tooltip="$ts.reply"> + <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" v-tooltip="$ts.renote"> + <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" v-tooltip="$ts.reaction"> + <i class="fas fa-plus"></i> + </button> + <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton" v-tooltip="$ts.reaction"> + <i class="fas fa-minus"></i> + </button> + <button class="button _button" @click="menu()" ref="menuButton"> + <i class="fas fa-ellipsis-h"></i> + </button> + </footer> + </div> + </article> +</div> +<div v-else class="muted" @click="muted = false"> + <I18n :src="$ts.userSaysSomething" tag="small"> + <template #name> + <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> + <MkUserName :user="appearNote.user"/> + </MkA> + </template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +import * as mfm from 'mfm-js'; +import { sum } from '@/scripts/array'; +import XSub from './note.sub.vue'; +import XNoteHeader from './note-header.vue'; +import XNoteSimple from './note-preview.vue'; +import XReactionsViewer from '@/components/reactions-viewer.vue'; +import XMediaList from '@/components/media-list.vue'; +import XCwButton from '@/components/cw-button.vue'; +import XPoll from '@/components/poll.vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { focusPrev, focusNext } from '@/scripts/focus'; +import { url } from '@/config'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { checkWordMute } from '@/scripts/check-word-mute'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { noteActions, noteViewInterruptors } from '@/store'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; + +export default defineComponent({ + components: { + XSub, + XNoteHeader, + XNoteSimple, + XReactionsViewer, + XMediaList, + XCwButton, + XPoll, + MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), + }, + + inject: { + inChannel: { + default: null + }, + }, + + props: { + note: { + type: Object, + required: true + }, + pinned: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['update:note'], + + data() { + return { + connection: null, + replies: [], + showContent: false, + collapsed: false, + isDeleted: false, + muted: false, + operating: 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(); + this.operating = true; + os.post({ + reply: this.appearNote, + animation: !viaKeyboard, + }, () => { + this.operating = false; + this.focus(); + }); + }, + + renote(viaKeyboard = false) { + pleaseLogin(); + this.operating = true; + 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 + }).then(() => { + this.operating = false; + }); + }, + + 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, + }); + } + }); + }, + + async react(viaKeyboard = false) { + pleaseLogin(); + this.operating = true; + this.blur(); + reactionPicker.show(this.$refs.reactButton, reaction => { + os.api('notes/reactions/create', { + noteId: this.appearNote.id, + reaction: reaction + }); + }, () => { + this.operating = false; + 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 + }); + }, + + 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 + }, + 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, + this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: this.$ts.unpin, + action: () => this.togglePin(false) + } : { + icon: 'fas fa-thumbtack', + text: this.$ts.pin, + action: () => this.togglePin(true) + } : undefined, + ...(this.$i.isModerator || this.$i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: this.$ts.promote, + action: this.promote + }] + : [] + ), + ...(this.appearNote.userId != this.$i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: this.$ts.reportAbuse, + action: () => { + const u = `${url}/notes/${this.appearNote.id}`; + os.popup(import('@/components/abuse-report-window.vue'), { + user: this.appearNote.user, + initialComment: `Note: ${u}\n-----\n` + }, {}, 'closed'); + } + }] + : [] + ), + ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ + null, + this.appearNote.userId == this.$i.id ? { + icon: 'fas fa-edit', + text: this.$ts.deleteAndEdit, + action: this.delEdit + } : undefined, + { + icon: 'fas fa-trash-alt', + text: this.$ts.delete, + danger: true, + action: this.del + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'fas fa-copy', + text: this.$ts.copyContent, + action: this.copyContent + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: this.copyLink + }, (this.appearNote.url || this.appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: this.$ts.showOnRemote, + action: () => { + window.open(this.appearNote.url || this.appearNote.uri, '_blank'); + } + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'fas fa-plug', + text: action.title, + action: () => { + action.handler(this.appearNote); + } + }))]); + } + + return menu; + }, + + onContextmenu(e) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(e.target)) return; + if (window.getSelection().toString() !== '') return; + + if (this.$store.state.useReactionPickerForContextMenu) { + e.preventDefault(); + this.react(); + } else { + os.contextMenu(this.getMenu(), e).then(this.focus); + } + }, + + menu(viaKeyboard = false) { + this.operating = true; + os.popupMenu(this.getMenu(), this.$refs.menuButton, { + viaKeyboard + }).then(() => { + this.operating = false; + 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}` + }); + }, + + focus() { + this.$el.focus(); + }, + + blur() { + this.$el.blur(); + }, + + focusBefore() { + focusPrev(this.$el); + }, + + focusAfter() { + focusNext(this.$el); + }, + + userPage + } +}); +</script> + +<style lang="scss" scoped> +.vfzoeqcg { + position: relative; + contain: content; + + // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 + // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう + // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 + // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる + // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) + //content-visibility: auto; + //contain-intrinsic-size: 0 128px; + + &:focus-visible { + outline: none; + } + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &:hover, &.operating { + > .article > .main > .footer { + display: block; + } + } + + &.renote { + background: rgba(128, 255, 0, 0.05); + } + + &.highlighted { + background: rgba(255, 128, 0, 0.05); + } + + > .info { + display: flex; + align-items: center; + padding: 12px 16px 4px 16px; + line-height: 24px; + font-size: 85%; + white-space: pre; + color: #d28a3f; + + > i { + margin-right: 4px; + } + + > .hide { + margin-left: 16px; + color: inherit; + opacity: 0.7; + } + } + + > .info + .article { + padding-top: 8px; + } + + > .reply-to { + opacity: 0.7; + padding-bottom: 0; + } + + > .renote { + display: flex; + align-items: center; + padding: 12px 16px 4px 16px; + line-height: 28px; + white-space: pre; + color: var(--renote); + font-size: 0.9em; + + > .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: 8px; + font-size: 0.9em; + opacity: 0.7; + + > .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: 12px 16px; + + > .avatar { + flex-shrink: 0; + display: block; + position: sticky; + top: 0; + margin: 0 14px 0 0; + width: 46px; + height: 46px; + } + + > .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); + } + } + + > .files { + max-width: 500px; + } + + > .url-preview { + margin-top: 8px; + max-width: 500px; + } + + > .poll { + font-size: 80%; + max-width: 500px; + } + + > .renote { + padding: 8px 0; + + > * { + padding: 16px; + border: dashed 1px var(--renote); + border-radius: 8px; + } + } + } + + > .channel { + opacity: 0.7; + font-size: 80%; + } + } + + > .footer { + display: none; + position: absolute; + top: 8px; + right: 8px; + padding: 0 6px; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + > .button { + margin: 0; + padding: 8px; + opacity: 0.7; + + &:hover { + color: var(--accent); + } + + > .count { + display: inline; + margin: 0 0 0 8px; + opacity: 0.7; + } + + &.reacted { + color: var(--accent); + } + } + } + } + } + + > .reply { + border-top: solid 0.5px var(--divider); + } +} + +.muted { + padding: 8px 16px; + opacity: 0.7; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } +} +</style> diff --git a/packages/client/src/ui/chat/notes.vue b/packages/client/src/ui/chat/notes.vue new file mode 100644 index 0000000000..9103f717e6 --- /dev/null +++ b/packages/client/src/ui/chat/notes.vue @@ -0,0 +1,94 @@ +<template> +<div class=""> + <div class="_fullinfo" v-if="empty"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noNotes }}</div> + </div> + + <MkLoading v-if="fetching"/> + + <MkError v-if="error" @retry="init()"/> + + <div v-show="more && reversed" style="margin-bottom: var(--margin);"> + <MkButton style="margin: 0 auto;" @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> + + <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :ad="true"> + <XNote :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> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import paging from '@/scripts/paging'; +import XNote from './note.vue'; +import XList from './date-separated-list.vue'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + XNote, XList, MkButton, + }, + + mixins: [ + paging({ + before: (self) => { + self.$emit('before'); + }, + + after: (self, e) => { + self.$emit('after', e); + } + }), + ], + + props: { + pagination: { + required: true + }, + + prop: { + type: String, + required: false + } + }, + + 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> diff --git a/packages/client/src/ui/chat/pages/channel.vue b/packages/client/src/ui/chat/pages/channel.vue new file mode 100644 index 0000000000..5152af20f9 --- /dev/null +++ b/packages/client/src/ui/chat/pages/channel.vue @@ -0,0 +1,259 @@ +<template> +<div v-if="channel" class="hhizbblb"> + <div class="info" v-if="date"> + <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> + </div> + <div class="tl" ref="body"> + <div class="new" v-if="queue > 0" :style="{ width: width + 'px', bottom: bottom + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> + <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="true"/> + </div> + <div class="bottom"> + <div class="typers" v-if="typers.length > 0"> + <I18n :src="$ts.typingUsers" text-tag="span" class="users"> + <template #users> + <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> + </template> + </I18n> + <MkEllipsis/> + </div> + <XPostForm :channel="channel"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw } from 'vue'; +import * as Misskey from 'misskey-js'; +import XNotes from '../notes.vue'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; +import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; +import follow from '@/directives/follow-append'; +import XPostForm from '../post-form.vue'; +import MkInfo from '@/components/ui/info.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes, + XPostForm, + MkInfo, + }, + + directives: { + follow + }, + + provide() { + return { + inChannel: true + }; + }, + + props: { + channelId: { + type: String, + required: true + }, + }, + + data() { + return { + channel: null as Misskey.entities.Channel | null, + connection: null, + pagination: null, + baseQuery: { + includeMyRenotes: this.$store.state.showMyRenotes, + includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, + includeLocalRenotes: this.$store.state.showLocalRenotes + }, + queue: 0, + width: 0, + top: 0, + bottom: 0, + typers: [], + date: null, + [symbols.PAGE_INFO]: computed(() => ({ + title: this.channel ? this.channel.name : '-', + subtitle: this.channel ? this.channel.description : '-', + icon: 'fas fa-satellite-dish', + actions: [{ + icon: this.channel?.isFollowing ? 'fas fa-star' : 'far fa-star', + text: this.channel?.isFollowing ? this.$ts.unfollow : this.$ts.follow, + highlighted: this.channel?.isFollowing, + handler: this.toggleChannelFollow + }, { + icon: 'fas fa-search', + text: this.$ts.inChannelSearch, + handler: this.inChannelSearch + }, { + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }] + })), + }; + }, + + async created() { + this.channel = await os.api('channels/show', { channelId: this.channelId }); + + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + }; + + this.connection = markRaw(os.stream.useChannel('channel', { + channelId: this.channelId + })); + this.connection.on('note', prepend); + this.connection.on('typers', typers => { + this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers; + }); + + this.pagination = { + endpoint: 'channels/timeline', + reversed: true, + limit: 10, + params: init => ({ + channelId: this.channelId, + untilDate: this.date?.getTime(), + ...this.baseQuery + }) + }; + }, + + mounted() { + + }, + + beforeUnmount() { + this.connection.dispose(); + }, + + methods: { + focus() { + this.$refs.body.focus(); + }, + + goTop() { + const container = getScrollContainer(this.$refs.body); + container.scrollTop = 0; + }, + + queueUpdated(q) { + if (this.$refs.body.offsetWidth !== 0) { + const rect = this.$refs.body.getBoundingClientRect(); + this.width = this.$refs.body.offsetWidth; + this.top = rect.top; + this.bottom = this.$refs.body.offsetHeight; + } + this.queue = q; + }, + + async inChannelSearch() { + const { canceled, result: query } = await os.dialog({ + title: this.$ts.inChannelSearch, + input: true + }); + if (canceled || query == null || query === '') return; + router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.channelId}`); + }, + + async toggleChannelFollow() { + if (this.channel.isFollowing) { + await os.apiWithDialog('channels/unfollow', { + channelId: this.channel.id + }); + this.channel.isFollowing = false; + } else { + await os.apiWithDialog('channels/follow', { + channelId: this.channel.id + }); + this.channel.isFollowing = true; + } + }, + + openChannelMenu(ev) { + os.popupMenu([{ + text: this.$ts.copyUrl, + icon: 'fas fa-link', + action: () => { + copyToClipboard(`${url}/channels/${this.currentChannel.id}`); + } + }], ev.currentTarget || ev.target); + }, + + timetravel(date?: Date) { + this.date = date; + this.$refs.tl.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.hhizbblb { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; + + > .info { + padding: 16px 16px 0 16px; + } + + > .top { + padding: 16px 16px 0 16px; + } + + > .bottom { + padding: 0 16px 16px 16px; + position: relative; + + > .typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + background: var(--panel); + border-radius: 0 8px 0 0; + color: var(--fgTransparentWeak); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } + } + + > .tl { + position: relative; + padding: 16px 0; + flex: 1; + min-width: 0; + overflow: auto; + + > .new { + position: fixed; + z-index: 1000; + + > button { + display: block; + margin: 16px auto; + padding: 8px 16px; + border-radius: 32px; + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/pages/timeline.vue b/packages/client/src/ui/chat/pages/timeline.vue new file mode 100644 index 0000000000..f4dfdf891e --- /dev/null +++ b/packages/client/src/ui/chat/pages/timeline.vue @@ -0,0 +1,221 @@ +<template> +<div class="dbiokgaf"> + <div class="info" v-if="date"> + <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo> + </div> + <div class="top"> + <XPostForm/> + </div> + <div class="tl" ref="body"> + <div class="new" v-if="queue > 0" :style="{ width: width + 'px', top: top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div> + <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineComponent, markRaw } from 'vue'; +import XNotes from '../notes.vue'; +import * as os from '@/os'; +import * as sound from '@/scripts/sound'; +import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll'; +import follow from '@/directives/follow-append'; +import XPostForm from '../post-form.vue'; +import MkInfo from '@/components/ui/info.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XNotes, + XPostForm, + MkInfo, + }, + + directives: { + follow + }, + + props: { + src: { + type: String, + required: true + }, + }, + + 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: {}, + queue: 0, + width: 0, + top: 0, + bottom: 0, + typers: [], + date: null, + [symbols.PAGE_INFO]: computed(() => ({ + title: this.$ts.timeline, + icon: 'fas fa-home', + actions: [{ + icon: 'fas fa-calendar-alt', + text: this.$ts.jumpToSpecifiedDate, + handler: this.timetravel + }] + })), + }; + }, + + created() { + const prepend = note => { + (this.$refs.tl as any).prepend(note); + + this.$emit('note'); + + sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); + }; + + const onChangeFollowing = () => { + if (!this.$refs.tl.backed) { + this.$refs.tl.reload(); + } + }; + + let endpoint; + + 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); + } + + this.pagination = { + endpoint: endpoint, + limit: 10, + params: init => ({ + untilDate: this.date?.getTime(), + ...this.baseQuery, ...this.query + }) + }; + }, + + mounted() { + + }, + + beforeUnmount() { + this.connection.dispose(); + if (this.connection2) this.connection2.dispose(); + }, + + methods: { + focus() { + this.$refs.body.focus(); + }, + + goTop() { + const container = getScrollContainer(this.$refs.body); + container.scrollTop = 0; + }, + + queueUpdated(q) { + if (this.$refs.body.offsetWidth !== 0) { + const rect = this.$refs.body.getBoundingClientRect(); + this.width = this.$refs.body.offsetWidth; + this.top = rect.top; + this.bottom = this.$refs.body.offsetHeight; + } + this.queue = q; + }, + + timetravel(date?: Date) { + this.date = date; + this.$refs.tl.reload(); + } + } +}); +</script> + +<style lang="scss" scoped> +.dbiokgaf { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; + + > .info { + padding: 16px 16px 0 16px; + } + + > .top { + padding: 16px 16px 0 16px; + } + + > .bottom { + padding: 0 16px 16px 16px; + position: relative; + + > .typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + background: var(--panel); + border-radius: 0 8px 0 0; + color: var(--fgTransparentWeak); + + > .users { + > .user + .user:before { + content: ", "; + font-weight: normal; + } + + > .user:last-of-type:after { + content: " "; + } + } + } + } + + > .tl { + position: relative; + padding: 16px 0; + flex: 1; + min-width: 0; + overflow: auto; + + > .new { + position: fixed; + z-index: 1000; + + > button { + display: block; + margin: 16px auto; + padding: 8px 16px; + border-radius: 32px; + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/post-form.vue b/packages/client/src/ui/chat/post-form.vue new file mode 100644 index 0000000000..44461c4a58 --- /dev/null +++ b/packages/client/src/ui/chat/post-form.vue @@ -0,0 +1,772 @@ +<template> +<div class="pxiwixjf" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="form"> + <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> + <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" /> + <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> + <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> + <footer> + <div class="left"> + <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="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> + </div> + <div class="right"> + <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="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> + </div> + </footer> + </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 * as mfm from 'mfm-js'; +import { host, url } from '@/config'; +import { erase, unique } from '@/scripts/array'; +import { extractMentions } from '@/scripts/extract-mentions'; +import * as Acct from 'misskey-js/built/acct'; +import { formatTimeString } from '@/scripts/format-time-string'; +import { Autocomplete } from '@/scripts/autocomplete'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { notePostInterruptors, postFormActions } from '@/store'; +import { isMobile } from '@/scripts/is-mobile'; +import { throttle } from 'throttle-debounce'; + +export default defineComponent({ + components: { + XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')), + XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue')) + }, + + props: { + reply: { + type: Object, + required: false + }, + renote: { + type: Object, + required: false + }, + channel: { + type: String, + required: false + }, + mention: { + type: Object, + required: false + }, + specified: { + type: Object, + required: false + }, + initialText: { + type: String, + required: false + }, + initialNote: { + type: Object, + required: false + }, + share: { + type: Boolean, + required: false, + default: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + }, + + emits: ['posted', 'cancel', 'esc'], + + data() { + return { + posting: false, + text: '', + files: [], + poll: null, + useCw: 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, + visibleUsers: [], + autocomplete: null, + draghover: false, + quoteId: null, + recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), + imeText: '', + typing: throttle(3000, () => { + if (this.channel) { + os.stream.send('typingOnChannel', { channel: this.channel }); + } + }), + postFormActions, + }; + }, + + computed: { + draftKey(): string { + let key = this.channel ? `channel:${this.channel}` : ''; + + 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; + } + }, + + mounted() { + if (this.initialText) { + this.text = this.initialText; + } + + 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' }); + + 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 = init.poll; + } + 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()); + }, + + 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('@/components/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 : 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 + }; + + // 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 (this.text && this.text != '') { + const hashtags = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); + const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; + localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); + } + this.posting = false; + }); + }).catch(err => { + this.posting = false; + os.dialog({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); + }, + + cancel() { + this.$emit('cancel'); + }, + + insertMention() { + os.selectUser().then(user => { + insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' '); + }); + }, + + async insertEmoji(ev) { + os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); + }, + + showActions(ev) { + os.popupMenu(postFormActions.map(action => ({ + text: action.title, + action: () => { + action.handler({ + text: this.text + }, (key, value) => { + if (key === 'text') { this.text = value; } + }); + } + })), ev.currentTarget || ev.target); + } + } +}); +</script> + +<style lang="scss" scoped> +.pxiwixjf { + position: relative; + border: solid 0.5px var(--divider); + border-radius: 8px; + + > .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; + } + } + } + } + + > .cw, + > .text { + display: block; + box-sizing: border-box; + padding: 16px; + 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); + } + + > .text { + max-width: 100%; + min-width: 100%; + min-height: 60px; + + &.withCw { + padding-top: 8px; + } + } + + > footer { + $height: 44px; + display: flex; + padding: 0 8px 8px 8px; + line-height: $height; + + > .left { + > button { + display: inline-block; + padding: 0; + margin: 0; + font-size: 16px; + width: $height; + height: $height; + border-radius: 6px; + + &:hover { + background: var(--X5); + } + + &.active { + color: var(--accent); + } + } + } + + > .right { + margin-left: auto; + + > .text-count { + opacity: 0.7; + } + + > .visibility { + width: $height; + margin: 0 8px; + + & + .localOnly { + margin-left: 0 !important; + } + } + + > .local-only { + margin: 0 0 0 12px; + opacity: 0.7; + } + + > .submit { + margin: 0; + padding: 0 12px; + line-height: 34px; + font-weight: bold; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + } + + > i { + margin-left: 6px; + } + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/chat/side.vue b/packages/client/src/ui/chat/side.vue new file mode 100644 index 0000000000..73881b23c0 --- /dev/null +++ b/packages/client/src/ui/chat/side.vue @@ -0,0 +1,157 @@ +<template> +<div class="mrajymqm _narrow_" v-if="component"> + <header class="header" @contextmenu.prevent.stop="onContextmenu"> + <MkHeader class="title" :info="pageInfo" :center="false"/> + </header> + <component :is="component" v-bind="props" :ref="changePage" class="body"/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + }, + + provide() { + return { + navHook: (path) => { + this.navigate(path); + } + }; + }, + + data() { + return { + path: null, + component: null, + props: {}, + pageInfo: null, + history: [], + }; + }, + + computed: { + url(): string { + return url + this.path; + } + }, + + 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.path) this.history.push(this.path); + this.path = path; + const { component, props } = resolve(path); + this.component = component; + this.props = props; + this.$emit('open'); + }, + + back() { + this.navigate(this.history.pop(), false); + }, + + close() { + this.path = null; + this.component = null; + this.props = {}; + this.$emit('close'); + }, + + onContextmenu(e) { + os.contextMenu([{ + type: 'label', + text: this.path, + }, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: () => { + this.$router.push(this.path); + this.close(); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.path); + this.close(); + } + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.close(); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }], e); + } + } +}); +</script> + +<style lang="scss" scoped> +.mrajymqm { + $header-height: 54px; // TODO: どこかに集約したい + + --root-margin: 16px; + --margin: var(--marginHalf); + + height: 100%; + overflow: auto; + box-sizing: border-box; + + > .header { + display: flex; + position: sticky; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + font-weight: bold; + //background-color: var(--panel); + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--header); + border-bottom: solid 0.5px var(--divider); + box-sizing: border-box; + + > ._button { + height: $header-height; + width: $header-height; + + &:hover { + color: var(--fgHighlighted); + } + } + + > .title { + flex: 1; + position: relative; + } + } + + > .body { + + } +} +</style> + diff --git a/packages/client/src/ui/chat/store.ts b/packages/client/src/ui/chat/store.ts new file mode 100644 index 0000000000..389d56afb6 --- /dev/null +++ b/packages/client/src/ui/chat/store.ts @@ -0,0 +1,17 @@ +import { markRaw } from 'vue'; +import { Storage } from '../../pizzax'; + +export const store = markRaw(new Storage('chatUi', { + widgets: { + where: 'account', + default: [] as { + name: string; + id: string; + data: Record<string, any>; + }[] + }, + tl: { + where: 'deviceAccount', + default: 'home' + }, +})); diff --git a/packages/client/src/ui/chat/sub-note-content.vue b/packages/client/src/ui/chat/sub-note-content.vue new file mode 100644 index 0000000000..9c169ea546 --- /dev/null +++ b/packages/client/src/ui/chat/sub-note-content.vue @@ -0,0 +1,62 @@ +<template> +<div class="wrmlmaau"> + <div class="body"> + <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> + <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> + <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> + <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> + <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> + </div> + <details v-if="note.files.length > 0"> + <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> + <XMediaList :media-list="note.files"/> + </details> + <details v-if="note.poll"> + <summary>{{ $ts.poll }}</summary> + <XPoll :note="note"/> + </details> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPoll from '@/components/poll.vue'; +import XMediaList from '@/components/media-list.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XPoll, + XMediaList, + }, + props: { + note: { + type: Object, + required: true + } + }, + data() { + return { + }; + } +}); +</script> + +<style lang="scss" scoped> +.wrmlmaau { + overflow-wrap: break-word; + + > .body { + > .reply { + margin-right: 6px; + color: var(--accent); + } + + > .rp { + margin-left: 4px; + font-style: oblique; + color: var(--renote); + } + } +} +</style> diff --git a/packages/client/src/ui/chat/widgets.vue b/packages/client/src/ui/chat/widgets.vue new file mode 100644 index 0000000000..6b12f9dac9 --- /dev/null +++ b/packages/client/src/ui/chat/widgets.vue @@ -0,0 +1,62 @@ +<template> +<div class="qydbhufi"> + <XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> + + <button v-if="edit" @click="edit = false" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgetsExit }}</button> + <button v-else @click="edit = true" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/widgets.vue'; +import { store } from './store'; + +export default defineComponent({ + components: { + XWidgets, + }, + + data() { + return { + edit: false, + widgets: store.reactiveState.widgets + }; + }, + + methods: { + addWidget(widget) { + store.set('widgets', [widget, ...store.state.widgets]); + }, + + removeWidget(widget) { + store.set('widgets', store.state.widgets.filter(w => w.id != widget.id)); + }, + + updateWidget({ id, data }) { + // TODO: throttleしたい + store.set('widgets', store.state.widgets.map(w => w.id === id ? { + ...w, + data: data + } : w)); + }, + + updateWidgets(widgets) { + store.set('widgets', widgets); + } + } +}); +</script> + +<style lang="scss" scoped> +.qydbhufi { + height: 100%; + box-sizing: border-box; + overflow: auto; + padding: var(--margin); + + ::v-deep(._panel) { + box-shadow: none; + } +} +</style> diff --git a/packages/client/src/ui/classic.header.vue b/packages/client/src/ui/classic.header.vue new file mode 100644 index 0000000000..c2e3bbd69b --- /dev/null +++ b/packages/client/src/ui/classic.header.vue @@ -0,0 +1,210 @@ +<template> +<div class="azykntjl"> + <div class="body"> + <div class="left"> + <MkA class="item index" active-class="active" to="/" exact v-click-anime v-tooltip="$ts.timeline"> + <i class="fas fa-home fa-fw"></i> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime v-tooltip="$ts[menuDef[item].title]"> + <i class="fa-fw" :class="menuDef[item].icon"></i> + <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.controlPanel"> + <i class="fas fa-door-open fa-fw"></i> + </MkA> + <button class="item _button" @click="more" v-click-anime> + <i class="fas fa-ellipsis-h fa-fw"></i> + <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> + </button> + </div> + <div class="right"> + <MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.settings"> + <i class="fas fa-cog fa-fw"></i> + </MkA> + <button class="item _button account" @click="openAccountMenu" v-click-anime> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/> + </button> + <div class="post" @click="post"> + <MkButton class="button" gradate full rounded> + <i class="fas fa-pencil-alt fa-fw"></i> + </MkButton> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { openAccountMenu } from '@/account'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkButton, + }, + + data() { + return { + host: host, + accounts: [], + connection: null, + menuDef: menuDef, + settingsWindowed: false, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + + otherNavItemIndicated(): boolean { + for (const def in this.menuDef) { + if (this.menu.includes(def)) continue; + if (this.menuDef[def].indicated) return true; + } + return false; + }, + }, + + watch: { + '$store.reactiveState.menuDisplay.value'() { + this.calcViewState(); + }, + }, + + created() { + window.addEventListener('resize', this.calcViewState); + this.calcViewState(); + }, + + methods: { + calcViewState() { + this.settingsWindowed = (window.innerWidth > 1400); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + more(ev) { + os.popup(import('@/components/launch-pad.vue'), {}, { + }, 'closed'); + }, + + openAccountMenu, + } +}); +</script> + +<style lang="scss" scoped> +.azykntjl { + $height: 60px; + $avatar-size: 32px; + $avatar-margin: 8px; + + position: sticky; + top: 0; + z-index: 1000; + width: 100%; + height: $height; + background-color: var(--bg); + + > .body { + max-width: 1380px; + margin: 0 auto; + display: flex; + + > .right, + > .left { + + > .item { + position: relative; + font-size: 0.9em; + display: inline-block; + padding: 0 12px; + line-height: $height; + + > i, + > .avatar { + margin-right: 0; + } + + > i { + left: 10px; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + } + + > .divider { + display: inline-block; + height: 16px; + margin: 0 10px; + border-right: solid 0.5px var(--divider); + } + + > .post { + display: inline-block; + + > .button { + width: 40px; + height: 40px; + padding: 0; + min-width: 0; + } + } + + > .account { + display: inline-flex; + align-items: center; + vertical-align: top; + margin-right: 8px; + + > .acct { + margin-left: 8px; + } + } + } + + > .right { + margin-left: auto; + } + } +} +</style> diff --git a/packages/client/src/ui/classic.side.vue b/packages/client/src/ui/classic.side.vue new file mode 100644 index 0000000000..38087cebb8 --- /dev/null +++ b/packages/client/src/ui/classic.side.vue @@ -0,0 +1,158 @@ +<template> +<div class="qvzfzxam _narrow_" v-if="component"> + <div class="container"> + <header class="header" @contextmenu.prevent.stop="onContextmenu"> + <button class="_button" @click="back()" v-if="history.length > 0"><i class="fas fa-chevron-left"></i></button> + <button class="_button" style="pointer-events: none;" v-else><!-- マージンのバランスを取るためのダミー --></button> + <span class="title">{{ pageInfo.title }}</span> + <button class="_button" @click="close()"><i class="fas fa-times"></i></button> + </header> + <MkHeader class="pageHeader" :info="pageInfo"/> + <component :is="component" v-bind="props" :ref="changePage"/> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { resolve } from '@/router'; +import { url } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + provide() { + return { + navHook: (path) => { + this.navigate(path); + } + }; + }, + + data() { + return { + path: null, + component: null, + props: {}, + pageInfo: null, + history: [], + }; + }, + + computed: { + url(): string { + return url + this.path; + } + }, + + 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.path) 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.path = null; + this.component = null; + this.props = {}; + }, + + onContextmenu(e) { + os.contextMenu([{ + type: 'label', + text: this.path, + }, { + icon: 'fas fa-expand-alt', + text: this.$ts.showInPage, + action: () => { + this.$router.push(this.path); + this.close(); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(this.path); + this.close(); + } + }, null, { + icon: 'fas fa-external-link-alt', + text: this.$ts.openInNewTab, + action: () => { + window.open(this.url, '_blank'); + this.close(); + } + }, { + icon: 'fas fa-link', + text: this.$ts.copyLink, + action: () => { + copyToClipboard(this.url); + } + }], e); + } + } +}); +</script> + +<style lang="scss" scoped> +.qvzfzxam { + $header-height: 58px; // TODO: どこかに集約したい + + --root-margin: 16px; + --margin: var(--marginHalf); + + > .container { + position: fixed; + width: 370px; + height: 100vh; + overflow: auto; + box-sizing: border-box; + + > .header { + display: flex; + position: sticky; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + line-height: $header-height; + text-align: center; + font-weight: bold; + //background-color: var(--panel); + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--header); + + > ._button { + height: $header-height; + width: $header-height; + + &:hover { + color: var(--fgHighlighted); + } + } + + > .title { + flex: 1; + position: relative; + } + } + } +} +</style> + diff --git a/packages/client/src/ui/classic.sidebar.vue b/packages/client/src/ui/classic.sidebar.vue new file mode 100644 index 0000000000..5e4b6ae28f --- /dev/null +++ b/packages/client/src/ui/classic.sidebar.vue @@ -0,0 +1,263 @@ +<template> +<div class="npcljfve" :class="{ iconOnly }"> + <button class="item _button account" @click="openAccountMenu" v-click-anime> + <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> + </button> + <div class="post" @click="post" data-cy-open-post-form> + <MkButton class="button" gradate full rounded> + <i class="fas fa-pencil-alt fa-fw"></i><span class="text" v-if="!iconOnly">{{ $ts.note }}</span> + </MkButton> + </div> + <div class="divider"></div> + <MkA class="item index" active-class="active" to="/" exact v-click-anime> + <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> + </MkA> + <template v-for="item in menu"> + <div v-if="item === '-'" class="divider"></div> + <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime> + <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> + <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> + </component> + </template> + <div class="divider"></div> + <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime> + <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + </MkA> + <button class="item _button" @click="more" v-click-anime> + <i class="fas fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> + <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> + </button> + <MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime> + <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> + </MkA> + <div class="divider"></div> + <div class="about"> + <MkA class="link" to="/about" v-click-anime> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/> + </MkA> + </div> + <!--<MisskeyLogo class="misskey"/>--> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { openAccountMenu } from '@/account'; +import MkButton from '@/components/ui/button.vue'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +//import MisskeyLogo from '@assets/client/misskey.svg'; + +export default defineComponent({ + components: { + MkButton, + //MisskeyLogo, + }, + + data() { + return { + host: host, + accounts: [], + connection: null, + menuDef: menuDef, + iconOnly: false, + settingsWindowed: false, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + + otherNavItemIndicated(): boolean { + for (const def in this.menuDef) { + if (this.menu.includes(def)) continue; + if (this.menuDef[def].indicated) return true; + } + return false; + }, + }, + + watch: { + '$store.reactiveState.menuDisplay.value'() { + this.calcViewState(); + }, + + iconOnly() { + this.$nextTick(() => { + this.$emit('change-view-mode'); + }); + }, + }, + + created() { + window.addEventListener('resize', this.calcViewState); + this.calcViewState(); + }, + + mounted() { + const sticky = new StickySidebar(this.$el.parentElement, 16); + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); + }, + + methods: { + calcViewState() { + this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon'); + this.settingsWindowed = (window.innerWidth > 1400); + }, + + post() { + os.post(); + }, + + search() { + search(); + }, + + more(ev) { + os.popup(import('@/components/launch-pad.vue'), {}, { + }, 'closed'); + }, + + openAccountMenu, + } +}); +</script> + +<style lang="scss" scoped> +.npcljfve { + $ui-font-size: 1em; // TODO: どこかに集約したい + $nav-icon-only-width: 78px; // TODO: どこかに集約したい + $avatar-size: 32px; + $avatar-margin: 8px; + + padding: 0 16px; + box-sizing: border-box; + width: 260px; + + &.iconOnly { + flex: 0 0 $nav-icon-only-width; + width: $nav-icon-only-width !important; + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + } + + > .post { + > .button { + width: 46px; + height: 46px; + padding: 0; + } + } + + > .item { + padding-left: 0; + width: 100%; + text-align: center; + font-size: $ui-font-size * 1.1; + line-height: 3.7rem; + + > i, + > .avatar { + margin-right: 0; + } + + > i { + left: 10px; + } + + > .text { + display: none; + } + } + } + + > .divider { + margin: 10px 0; + border-top: solid 0.5px var(--divider); + } + + > .post { + position: sticky; + top: 0; + z-index: 1; + padding: 16px 0; + background: var(--bg); + + > .button { + min-width: 0; + } + } + + > .about { + fill: currentColor; + padding: 8px 0 16px 0; + text-align: center; + + > .link { + display: block; + width: 32px; + margin: 0 auto; + + img { + display: block; + width: 100%; + } + } + } + + > .item { + position: relative; + display: block; + font-size: $ui-font-size; + line-height: 2.6rem; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + text-align: left; + box-sizing: border-box; + + > i { + width: 32px; + } + + > i, + > .avatar { + margin-right: $avatar-margin; + } + + > .avatar { + width: $avatar-size; + height: $avatar-size; + vertical-align: middle; + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--navIndicator); + font-size: 8px; + animation: blink 1s infinite; + } + + &:hover { + text-decoration: none; + color: var(--navHoverFg); + } + + &.active { + color: var(--navActive); + } + } +} +</style> diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue new file mode 100644 index 0000000000..5d7c79a0e2 --- /dev/null +++ b/packages/client/src/ui/classic.vue @@ -0,0 +1,471 @@ +<template> +<div class="mk-app" :class="{ wallpaper, isMobile }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`"> + <XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/> + + <div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }"> + <template v-if="!isMobile"> + <div class="sidebar" v-if="!showMenuOnTop"> + <XSidebar/> + </div> + <div class="widgets left" ref="widgetsLeft" v-else> + <XWidgets @mounted="attachSticky('widgetsLeft')" :place="'left'"/> + </div> + </template> + + <main class="main" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }"> + <div class="content"> + <MkStickyContainer> + <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage"/> + </keep-alive> + </transition> + </router-view> + </MkStickyContainer> + </div> + </main> + + <div v-if="isDesktop" class="widgets right" ref="widgetsRight"> + <XWidgets @mounted="attachSticky('widgetsRight')" :place="null"/> + </div> + </div> + + <div class="buttons" v-if="isMobile"> + <button class="button nav _button" @click="showDrawerNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> + <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button> + <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> + <button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> + <button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button> + </div> + + <XDrawerSidebar ref="drawerNav" class="sidebar" v-if="isMobile"/> + + <transition name="tray-back"> + <div class="tray-back _modalBg" + v-if="widgetsShowing" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> + </transition> + + <transition name="tray"> + <XWidgets v-if="widgetsShowing" class="tray"/> + </transition> + + <iframe v-if="$store.state.aiChanMode" class="ivnzpscs" ref="live2d" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent, markRaw } from 'vue'; +import { instanceName } from '@/config'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +import XSidebar from './classic.sidebar.vue'; +import XDrawerSidebar from '@/ui/_common_/sidebar.vue'; +import XCommon from './_common_/common.vue'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import * as symbols from '@/symbols'; + +const DESKTOP_THRESHOLD = 1100; +const MOBILE_THRESHOLD = 600; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + XDrawerSidebar, + XHeaderMenu: defineAsyncComponent(() => import('./classic.header.vue')), + XWidgets: defineAsyncComponent(() => import('./classic.widgets.vue')), + }, + + provide() { + return { + shouldHeaderThin: this.showMenuOnTop, + }; + }, + + data() { + return { + pageInfo: null, + menuDef: menuDef, + globalHeaderHeight: 0, + isMobile: window.innerWidth <= MOBILE_THRESHOLD, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + widgetsShowing: false, + fullView: false, + wallpaper: localStorage.getItem('wallpaper') != null, + }; + }, + + computed: { + navIndicated(): boolean { + for (const def in this.menuDef) { + if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから + if (this.menuDef[def].indicated) return true; + } + return false; + }, + + showMenuOnTop(): boolean { + return !this.isMobile && this.$store.state.menuDisplay === 'top'; + } + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + if (this.$store.state.widgets.length === 0) { + this.$store.set('widgets', [{ + name: 'calendar', + id: 'a', place: null, data: {} + }, { + name: 'notifications', + id: 'b', place: null, data: {} + }, { + name: 'trends', + id: 'c', place: null, data: {} + }]); + } + }, + + mounted() { + window.addEventListener('resize', () => { + this.isMobile = (window.innerWidth <= MOBILE_THRESHOLD); + this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD); + }, { passive: true }); + + if (this.$store.state.aiChanMode) { + const iframeRect = this.$refs.live2d.getBoundingClientRect(); + window.addEventListener('mousemove', ev => { + this.$refs.live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + } + }, '*'); + }, { passive: true }); + window.addEventListener('touchmove', ev => { + this.$refs.live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.touches[0].clientX - iframeRect.left, + y: ev.touches[0].clientY - iframeRect.top, + } + }, '*'); + }, { passive: true }); + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + document.title = `${this.pageInfo.title} | ${instanceName}`; + } + }, + + attachSticky(ref) { + const sticky = new StickySidebar(this.$refs[ref], this.$store.state.menuDisplay === 'top' ? 0 : 16, this.$store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); + }, + + post() { + os.post(); + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + back() { + history.back(); + }, + + showDrawerNav() { + this.$refs.drawerNav.show(); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + 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 (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (window.getSelection().toString() !== '') return; + const path = this.$route.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: this.fullView ? 'fas fa-compress' : 'fas fa-expand', + text: this.fullView ? this.$ts.quitFullView : this.$ts.fullView, + action: () => { + this.fullView = !this.fullView; + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(path); + } + }], e); + }, + + onAiClick(ev) { + //if (this.live2d) this.live2d.click(ev); + } + } +}); +</script> + +<style lang="scss" scoped> +.tray-enter-active, +.tray-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-enter-from, +.tray-leave-active { + opacity: 0; + transform: translateX(240px); +} + +.tray-back-enter-active, +.tray-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-back-enter-from, +.tray-back-leave-active { + opacity: 0; +} + +.mk-app { + $ui-font-size: 1em; + $widgets-hide-threshold: 1200px; + $nav-icon-only-width: 78px; // TODO: どこかに集約したい + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + + &.wallpaper { + background: var(--wallpaperOverlay); + //backdrop-filter: var(--blur, blur(4px)); + } + + &.isMobile { + > .columns { + display: block; + margin: 0; + + > .main { + margin: 0; + padding-bottom: 92px; + border: none; + width: 100%; + border-radius: 0; + } + } + } + + > .columns { + display: flex; + justify-content: center; + max-width: 100%; + //margin: 32px 0; + + &.fullView { + margin: 0; + + > .sidebar { + display: none; + } + + > .widgets { + display: none; + } + + > .main { + margin: 0; + border-radius: 0; + box-shadow: none; + width: 100%; + } + } + + > .main { + min-width: 0; + width: 750px; + margin: 0 16px 0 0; + background: var(--panel); + border-left: solid 1px var(--divider); + border-right: solid 1px var(--divider); + border-radius: 0; + overflow: clip; + --margin: 12px; + } + + > .widgets { + //--panelBorder: none; + width: 300px; + margin-top: 16px; + + @media (max-width: $widgets-hide-threshold) { + display: none; + } + + &.left { + margin-right: 16px; + } + } + + > .sidebar { + margin-top: 16px; + } + + &.withGlobalHeader { + > .main { + margin-top: 0; + border: solid 1px var(--divider); + border-radius: var(--radius); + --stickyTop: var(--globalHeaderHeight); + } + + > .widgets { + --stickyTop: var(--globalHeaderHeight); + margin-top: 0; + } + } + + @media (max-width: 850px) { + margin: 0; + + > .sidebar { + border-right: solid 0.5px var(--divider); + } + + > .main { + margin: 0; + border-radius: 0; + box-shadow: none; + width: 100%; + } + } + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + padding: 16px; + display: flex; + width: 100%; + box-sizing: border-box; + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--header); + border-top: solid 0.5px var(--divider); + + > .button { + position: relative; + flex: 1; + padding: 0; + margin: auto; + height: 64px; + border-radius: 8px; + background: var(--panel); + color: var(--fg); + + &:not(:last-child) { + margin-right: 12px; + } + + @media (max-width: 400px) { + height: 60px; + + &:not(:last-child) { + margin-right: 8px; + } + } + + &:hover { + background: var(--X2); + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--indicator); + font-size: 16px; + animation: blink 1s infinite; + } + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + > * { + font-size: 22px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + } + } + + > .tray-back { + z-index: 1001; + } + + > .tray { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + padding: var(--margin); + box-sizing: border-box; + overflow: auto; + background: var(--bg); + } + + > .ivnzpscs { + position: fixed; + bottom: 0; + right: 0; + width: 300px; + height: 600px; + border: none; + pointer-events: none; + } +} +</style> diff --git a/packages/client/src/ui/classic.widgets.vue b/packages/client/src/ui/classic.widgets.vue new file mode 100644 index 0000000000..562c2eeb2c --- /dev/null +++ b/packages/client/src/ui/classic.widgets.vue @@ -0,0 +1,84 @@ +<template> +<div class="ddiqwdnk"> + <XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> + <MkAd class="a" :prefer="['square']"/> + + <button v-if="editMode" @click="editMode = false" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button> + <button v-else @click="editMode = true" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/widgets.vue'; + +export default defineComponent({ + components: { + XWidgets + }, + + props: { + place: { + type: String, + } + }, + + emits: ['mounted'], + + data() { + return { + editMode: false, + }; + }, + + mounted() { + this.$emit('mounted', this.$el); + }, + + methods: { + addWidget(widget) { + this.$store.set('widgets', [{ + ...widget, + place: this.place, + }, ...this.$store.state.widgets]); + }, + + removeWidget(widget) { + this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id)); + }, + + updateWidget({ id, data }) { + this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? { + ...w, + data: data + } : w)); + }, + + updateWidgets(widgets) { + this.$store.set('widgets', [ + ...this.$store.state.widgets.filter(w => w.place !== this.place), + ...widgets + ]); + } + } +}); +</script> + +<style lang="scss" scoped> +.ddiqwdnk { + position: sticky; + height: min-content; + box-sizing: border-box; + padding-bottom: 8px; + + > .widgets, + > .a { + width: 300px; + } + + > .edit { + display: block; + margin: 16px auto; + } +} +</style> diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue new file mode 100644 index 0000000000..cc8bf5a511 --- /dev/null +++ b/packages/client/src/ui/deck.vue @@ -0,0 +1,229 @@ +<template> +<div class="mk-deck" :class="`${deckStore.reactiveState.columnAlign.value}`" @contextmenu.self.prevent="onContextmenu" + :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }" +> + <XSidebar ref="nav"/> + + <template v-for="ids in layout"> + <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> + <section v-if="ids.length > 1" + class="folder column" + :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" + > + <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> + </section> + <DeckColumnCore v-else + class="column" + :ref="ids[0]" + :key="ids[0]" + :column="columns.find(c => c.id === ids[0])" + @parent-focus="moveFocus(ids[0], $event)" + :style="columns.find(c => c.id === ids[0]).flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0]).width + 'px' }" + /> + </template> + + <button v-if="$i" class="nav _button" @click="showNav()"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> + <button v-if="$i" class="post _buttonPrimary" @click="post()"><i class="fas fa-pencil-alt"></i></button> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import { host } from '@/config'; +import DeckColumnCore from '@/ui/deck/column-core.vue'; +import XSidebar from '@/ui/_common_/sidebar.vue'; +import { getScrollContainer } from '@/scripts/scroll'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import XCommon from './_common_/common.vue'; +import { deckStore, addColumn, loadDeck } from './deck/deck-store'; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + DeckColumnCore, + }, + + provide() { + return deckStore.state.navWindow ? { + navHook: (url) => { + os.pageWindow(url); + } + } : {}; + }, + + data() { + return { + deckStore, + host: host, + menuDef: menuDef, + wallpaper: localStorage.getItem('wallpaper') != null, + }; + }, + + computed: { + columns() { + return deckStore.reactiveState.columns.value; + }, + layout() { + return deckStore.reactiveState.layout.value; + }, + navIndicated(): boolean { + if (!this.$i) return false; + for (const def in this.menuDef) { + if (this.menuDef[def].indicated) return true; + } + return false; + }, + }, + + created() { + document.documentElement.style.overflowY = 'hidden'; + document.documentElement.style.scrollBehavior = 'auto'; + window.addEventListener('wheel', this.onWheel); + loadDeck(); + }, + + mounted() { + }, + + methods: { + onWheel(e) { + if (getScrollContainer(e.target) == null) { + document.documentElement.scrollLeft += e.deltaY > 0 ? 96 : -96; + } + }, + + showNav() { + this.$refs.nav.show(); + }, + + post() { + os.post(); + }, + + async addColumn(ev) { + const columns = [ + 'main', + 'widgets', + 'notifications', + 'tl', + 'antenna', + 'list', + 'mentions', + 'direct', + ]; + + const { canceled, result: column } = await os.dialog({ + title: this.$ts._deck.addColumn, + type: null, + select: { + items: columns.map(column => ({ + value: column, text: this.$t('_deck._columns.' + column) + })) + }, + showCancelButton: true + }); + if (canceled) return; + + addColumn({ + type: column, + id: uuid(), + name: this.$t('_deck._columns.' + column), + width: 330, + }); + }, + + onContextmenu(e) { + os.contextMenu([{ + text: this.$ts._deck.addColumn, + icon: null, + action: this.addColumn + }], e); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-deck { + $nav-hide-threshold: 650px; // TODO: どこかに集約したい + + // TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい + --margin: var(--marginHalf); + + display: flex; + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + flex: 1; + padding: var(--deckMargin); + + &.center { + > .column:first-of-type { + margin-left: auto; + } + + > .column:last-of-type { + margin-right: auto; + } + } + + > .column { + flex-shrink: 0; + margin-right: var(--deckMargin); + + &.folder { + display: flex; + flex-direction: column; + + > *:not(:last-child) { + margin-bottom: var(--deckMargin); + } + } + } + + > .post, + > .nav { + position: fixed; + z-index: 1000; + bottom: 32px; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + font-size: 22px; + + @media (min-width: ($nav-hide-threshold + 1px)) { + display: none; + } + } + + > .post { + right: 32px; + } + + > .nav { + left: 32px; + background: var(--panel); + color: var(--fg); + + &:hover { + background: var(--X2); + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--indicator); + font-size: 16px; + animation: blink 1s infinite; + } + } +} +</style> diff --git a/packages/client/src/ui/deck/antenna-column.vue b/packages/client/src/ui/deck/antenna-column.vue new file mode 100644 index 0000000000..d42b8a5a10 --- /dev/null +++ b/packages/client/src/ui/deck/antenna-column.vue @@ -0,0 +1,80 @@ +<template> +<XColumn :func="{ handler: setAntenna, title: $ts.selectAntenna }" :column="column" :is-stacked="isStacked"> + <template #header> + <i class="fas fa-satellite"></i><span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XColumn from './column.vue'; +import XTimeline from '@/components/timeline.vue'; +import * as os from '@/os'; +import { updateColumn } from './deck-store'; + +export default defineComponent({ + components: { + XColumn, + XTimeline, + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + }; + }, + + watch: { + mediaOnly() { + (this.$refs.timeline as any).reload(); + } + }, + + mounted() { + if (this.column.antennaId == null) { + this.setAntenna(); + } + }, + + methods: { + async setAntenna() { + const antennas = await os.api('antennas/list'); + const { canceled, result: antenna } = await os.dialog({ + title: this.$ts.selectAntenna, + type: null, + select: { + items: antennas.map(x => ({ + value: x, text: x.name + })), + default: this.column.antennaId + }, + showCancelButton: true + }); + if (canceled) return; + updateColumn(this.column.id, { + antennaId: antenna.id + }); + }, + + focus() { + (this.$refs.timeline as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/ui/deck/column-core.vue b/packages/client/src/ui/deck/column-core.vue new file mode 100644 index 0000000000..5393bac736 --- /dev/null +++ b/packages/client/src/ui/deck/column-core.vue @@ -0,0 +1,52 @@ +<template> +<!-- TODO: リファクタの余地がありそう --> +<XMainColumn v-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XMainColumn from './main-column.vue'; +import XTlColumn from './tl-column.vue'; +import XAntennaColumn from './antenna-column.vue'; +import XListColumn from './list-column.vue'; +import XNotificationsColumn from './notifications-column.vue'; +import XWidgetsColumn from './widgets-column.vue'; +import XMentionsColumn from './mentions-column.vue'; +import XDirectColumn from './direct-column.vue'; + +export default defineComponent({ + components: { + XMainColumn, + XTlColumn, + XAntennaColumn, + XListColumn, + XNotificationsColumn, + XWidgetsColumn, + XMentionsColumn, + XDirectColumn + }, + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: false, + default: false + } + }, + methods: { + focus() { + this.$children[0].focus(); + } + } +}); +</script> diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue new file mode 100644 index 0000000000..fe112e3039 --- /dev/null +++ b/packages/client/src/ui/deck/column.vue @@ -0,0 +1,408 @@ +<template> +<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> +<section class="dnpfarvg _panel _narrow_" :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }" + @dragover.prevent.stop="onDragover" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + v-hotkey="keymap" + :style="{ '--deckColumnHeaderHeight': deckStore.reactiveState.columnHeaderHeight.value + 'px' }" +> + <header :class="{ indicated }" + draggable="true" + @click="goTop" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + > + <button class="toggleActive _button" @click="toggleActive" v-if="isStacked && !isMainColumn"> + <template v-if="active"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + <div class="action"> + <slot name="action"></slot> + </div> + <span class="header"><slot name="header"></slot></span> + <button v-if="func" class="menu _button" v-tooltip="func.title" @click.stop="func.handler"><i :class="func.icon || 'fas fa-cog'"></i></button> + </header> + <div ref="body" v-show="active"> + <slot></slot> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from './deck-store'; +import { deckStore } from './deck-store'; + +export default defineComponent({ + provide: { + shouldHeaderThin: true, + shouldOmitHeaderTitle: true, + }, + + props: { + column: { + type: Object, + required: false, + default: null + }, + isStacked: { + type: Boolean, + required: false, + default: false + }, + func: { + type: Object, + required: false, + default: null + }, + naked: { + type: Boolean, + required: false, + default: false + }, + indicated: { + type: Boolean, + required: false, + default: false + }, + }, + + data() { + return { + deckStore, + dragging: false, + draghover: false, + dropready: false, + }; + }, + + computed: { + isMainColumn(): boolean { + return this.column.type === 'main'; + }, + + active(): boolean { + return this.column.active !== false; + }, + + keymap(): any { + return { + 'shift+up': () => this.$parent.$emit('parent-focus', 'up'), + 'shift+down': () => this.$parent.$emit('parent-focus', 'down'), + 'shift+left': () => this.$parent.$emit('parent-focus', 'left'), + 'shift+right': () => this.$parent.$emit('parent-focus', 'right'), + }; + } + }, + + watch: { + active(v) { + this.$emit('change-active-state', v); + }, + + dragging(v) { + os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd'); + } + }, + + mounted() { + os.deckGlobalEvents.on('column.dragStart', this.onOtherDragStart); + os.deckGlobalEvents.on('column.dragEnd', this.onOtherDragEnd); + }, + + beforeUnmount() { + os.deckGlobalEvents.off('column.dragStart', this.onOtherDragStart); + os.deckGlobalEvents.off('column.dragEnd', this.onOtherDragEnd); + }, + + methods: { + onOtherDragStart() { + this.dropready = true; + }, + + onOtherDragEnd() { + this.dropready = false; + }, + + toggleActive() { + if (!this.isStacked) return; + updateColumn(this.column.id, { + active: !this.column.active + }); + }, + + getMenu() { + const items = [{ + icon: 'fas fa-pencil-alt', + text: this.$ts.edit, + action: async () => { + const { canceled, result } = await os.form(this.column.name, { + name: { + type: 'string', + label: this.$ts.name, + default: this.column.name + }, + width: { + type: 'number', + label: this.$ts.width, + default: this.column.width + }, + flexible: { + type: 'boolean', + label: this.$ts.flexible, + default: this.column.flexible + } + }); + if (canceled) return; + updateColumn(this.column.id, result); + } + }, null, { + icon: 'fas fa-arrow-left', + text: this.$ts._deck.swapLeft, + action: () => { + swapLeftColumn(this.column.id); + } + }, { + icon: 'fas fa-arrow-right', + text: this.$ts._deck.swapRight, + action: () => { + swapRightColumn(this.column.id); + } + }, this.isStacked ? { + icon: 'fas fa-arrow-up', + text: this.$ts._deck.swapUp, + action: () => { + swapUpColumn(this.column.id); + } + } : undefined, this.isStacked ? { + icon: 'fas fa-arrow-down', + text: this.$ts._deck.swapDown, + action: () => { + swapDownColumn(this.column.id); + } + } : undefined, null, { + icon: 'fas fa-window-restore', + text: this.$ts._deck.stackLeft, + action: () => { + stackLeftColumn(this.column.id); + } + }, this.isStacked ? { + icon: 'fas fa-window-maximize', + text: this.$ts._deck.popRight, + action: () => { + popRightColumn(this.column.id); + } + } : undefined, null, { + icon: 'fas fa-trash-alt', + text: this.$ts.remove, + danger: true, + action: () => { + removeColumn(this.column.id); + } + }]; + + return items; + }, + + onContextmenu(e) { + os.contextMenu(this.getMenu(), e); + }, + + goTop() { + this.$refs.body.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, this.column.id); + + // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう + // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately + setTimeout(() => { + this.dragging = true; + }, 10); + }, + + onDragend(e) { + this.dragging = false; + }, + + onDragover(e) { + // 自分自身がドラッグされている場合 + if (this.dragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isDeckColumn = e.dataTransfer.types[0] == _DATA_TRANSFER_DECK_COLUMN_; + + e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; + + if (!this.dragging && isDeckColumn) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + os.deckGlobalEvents.emit('column.dragEnd'); + + const id = e.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); + if (id != null && id != '') { + swapColumn(this.column.id, id); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.dnpfarvg { + --root-margin: 10px; + + height: 100%; + overflow: hidden; + contain: content; + box-shadow: 0 0 8px 0 var(--shadow); + + &.draghover { + box-shadow: 0 0 0 2px var(--focus); + + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + } + } + + &.dragging { + box-shadow: 0 0 0 2px var(--focus); + } + + &.dropready { + * { + pointer-events: none; + } + } + + &:not(.active) { + flex-basis: var(--deckColumnHeaderHeight); + min-height: var(--deckColumnHeaderHeight); + + > header.indicated { + box-shadow: 4px 0px var(--accent) inset; + } + } + + &.naked { + background: var(--acrylicBg) !important; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + + > header { + background: transparent; + box-shadow: none; + + > button { + color: var(--fg); + } + } + } + + &.paged { + background: var(--bg) !important; + } + + > header { + position: relative; + display: flex; + z-index: 2; + line-height: var(--deckColumnHeaderHeight); + height: var(--deckColumnHeaderHeight); + padding: 0 16px; + font-size: 0.9em; + color: var(--panelHeaderFg); + background: var(--panelHeaderBg); + box-shadow: 0 1px 0 0 var(--panelHeaderDivider); + cursor: pointer; + + &, * { + user-select: none; + } + + &.indicated { + box-shadow: 0 3px 0 0 var(--accent); + } + + > .header { + display: inline-block; + align-items: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + > span:only-of-type { + width: 100%; + } + + > .toggleActive, + > .action > ::v-deep(*), + > .menu { + z-index: 1; + width: var(--deckColumnHeaderHeight); + line-height: var(--deckColumnHeaderHeight); + font-size: 16px; + color: var(--faceTextButton); + + &:hover { + color: var(--faceTextButtonHover); + } + + &:active { + color: var(--faceTextButtonActive); + } + } + + > .toggleActive, > .action { + margin-left: -16px; + } + + > .action { + z-index: 1; + } + + > .action:empty { + display: none; + } + + > .menu { + margin-left: auto; + margin-right: -16px; + } + } + + > div { + height: calc(100% - var(--deckColumnHeaderHeight)); + overflow: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; + box-sizing: border-box; + } +} +</style> diff --git a/packages/client/src/ui/deck/deck-store.ts b/packages/client/src/ui/deck/deck-store.ts new file mode 100644 index 0000000000..6b6b02f3f9 --- /dev/null +++ b/packages/client/src/ui/deck/deck-store.ts @@ -0,0 +1,298 @@ +import { throttle } from 'throttle-debounce'; +import { i18n } from '@/i18n'; +import { api } from '@/os'; +import { markRaw, watch } from 'vue'; +import { Storage } from '../../pizzax'; + +type ColumnWidget = { + name: string; + id: string; + data: Record<string, any>; +}; + +type Column = { + id: string; + type: string; + name: string | null; + width: number; + widgets?: ColumnWidget[]; + active?: boolean; +}; + +function copy<T>(x: T): T { + return JSON.parse(JSON.stringify(x)); +} + +export const deckStore = markRaw(new Storage('deck', { + profile: { + where: 'deviceAccount', + default: 'default' + }, + columns: { + where: 'deviceAccount', + default: [] as Column[] + }, + layout: { + where: 'deviceAccount', + default: [] as Column['id'][][] + }, + columnAlign: { + where: 'deviceAccount', + default: 'left' as 'left' | 'right' | 'center' + }, + alwaysShowMainColumn: { + where: 'deviceAccount', + default: true + }, + navWindow: { + where: 'deviceAccount', + default: true + }, + columnMargin: { + where: 'deviceAccount', + default: 16 + }, + columnHeaderHeight: { + where: 'deviceAccount', + default: 42 + }, +})); + +export const loadDeck = async () => { + let deck; + + try { + deck = await api('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + }); + } catch (e) { + if (e.code === 'NO_SUCH_KEY') { + // 後方互換性のため + if (deckStore.state.profile === 'default') { + saveDeck(); + return; + } + + deckStore.set('columns', [{ + id: 'a', + type: 'main', + name: i18n.locale._deck._columns.main, + width: 350, + }, { + id: 'b', + type: 'notifications', + name: i18n.locale._deck._columns.notifications, + width: 330, + }]); + deckStore.set('layout', [['a'], ['b']]); + return; + } + throw e; + } + + deckStore.set('columns', deck.columns); + deckStore.set('layout', deck.layout); +}; + +// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する +export const saveDeck = throttle(1000, () => { + api('i/registry/set', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + value: { + columns: deckStore.reactiveState.columns.value, + layout: deckStore.reactiveState.layout.value, + } + }); +}); + +export function addColumn(column: Column) { + if (column.name == undefined) column.name = null; + deckStore.push('columns', column); + deckStore.push('layout', [column.id]); + saveDeck(); +} + +export function removeColumn(id: Column['id']) { + deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id)); + deckStore.set('layout', deckStore.state.layout + .map(ids => ids.filter(_id => _id !== id)) + .filter(ids => ids.length > 0)); + saveDeck(); +} + +export function swapColumn(a: Column['id'], b: Column['id']) { + const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) != -1); + const aY = deckStore.state.layout[aX].findIndex(id => id == a); + const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) != -1); + const bY = deckStore.state.layout[bX].findIndex(id => id == b); + const layout = copy(deckStore.state.layout); + layout[aX][aY] = b; + layout[bX][bY] = a; + deckStore.set('layout', layout); + saveDeck(); +} + +export function swapLeftColumn(id: Column['id']) { + const layout = copy(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const left = deckStore.state.layout[i - 1]; + if (left) { + layout[i - 1] = deckStore.state.layout[i]; + layout[i] = left; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapRightColumn(id: Column['id']) { + const layout = copy(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const right = deckStore.state.layout[i + 1]; + if (right) { + layout[i + 1] = deckStore.state.layout[i]; + layout[i] = right; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapUpColumn(id: Column['id']) { + const layout = copy(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = copy(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const up = ids[i - 1]; + if (up) { + ids[i - 1] = id; + ids[i] = up; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapDownColumn(id: Column['id']) { + const layout = copy(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = copy(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const down = ids[i + 1]; + if (down) { + ids[i + 1] = id; + ids[i] = down; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function stackLeftColumn(id: Column['id']) { + let layout = copy(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout[i - 1].push(id); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + saveDeck(); +} + +export function popRightColumn(id: Column['id']) { + let layout = copy(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const affected = layout[i]; + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout.splice(i + 1, 0, [id]); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + + const columns = copy(deckStore.state.columns); + for (const column of columns) { + if (affected.includes(column.id)) { + column.active = true; + } + } + deckStore.set('columns', columns); + + saveDeck(); +} + +export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = copy(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = copy(deckStore.state.columns[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets.unshift(widget); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = copy(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = copy(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.filter(w => w.id != widget.id); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { + const columns = copy(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = copy(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = widgets; + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumnWidget(id: Column['id'], widgetId: string, data: any) { + const columns = copy(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = copy(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.map(w => w.id === widgetId ? { + ...w, + data: data + } : w); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumn(id: Column['id'], column: Partial<Column>) { + const columns = copy(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const currentColumn = copy(deckStore.state.columns[columnIndex]); + if (currentColumn == null) return; + for (const [k, v] of Object.entries(column)) { + currentColumn[k] = v; + } + columns[columnIndex] = currentColumn; + deckStore.set('columns', columns); + saveDeck(); +} diff --git a/packages/client/src/ui/deck/direct-column.vue b/packages/client/src/ui/deck/direct-column.vue new file mode 100644 index 0000000000..a11b2e82ed --- /dev/null +++ b/packages/client/src/ui/deck/direct-column.vue @@ -0,0 +1,55 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked"> + <template #header><i class="fas fa-envelope" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XColumn from './column.vue'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XColumn, + XNotes + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + pagination: { + endpoint: 'notes/mentions', + limit: 10, + params: () => ({ + visibility: 'specified' + }) + }, + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/ui/deck/list-column.vue b/packages/client/src/ui/deck/list-column.vue new file mode 100644 index 0000000000..3ebba8032f --- /dev/null +++ b/packages/client/src/ui/deck/list-column.vue @@ -0,0 +1,80 @@ +<template> +<XColumn :func="{ handler: setList, title: $ts.selectList }" :column="column" :is-stacked="isStacked"> + <template #header> + <i class="fas fa-list-ul"></i><span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XColumn from './column.vue'; +import XTimeline from '@/components/timeline.vue'; +import * as os from '@/os'; +import { updateColumn } from './deck-store'; + +export default defineComponent({ + components: { + XColumn, + XTimeline, + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + }; + }, + + watch: { + mediaOnly() { + (this.$refs.timeline as any).reload(); + } + }, + + mounted() { + if (this.column.listId == null) { + this.setList(); + } + }, + + methods: { + async setList() { + const lists = await os.api('users/lists/list'); + const { canceled, result: list } = await os.dialog({ + title: this.$ts.selectList, + type: null, + select: { + items: lists.map(x => ({ + value: x, text: x.name + })), + default: this.column.listId + }, + showCancelButton: true + }); + if (canceled) return; + updateColumn(this.column.id, { + listId: list.id + }); + }, + + focus() { + (this.$refs.timeline as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue new file mode 100644 index 0000000000..744056881c --- /dev/null +++ b/packages/client/src/ui/deck/main-column.vue @@ -0,0 +1,91 @@ +<template> +<XColumn v-if="deckStore.state.alwaysShowMainColumn || $route.name !== 'index'" :column="column" :is-stacked="isStacked"> + <template #header> + <template v-if="pageInfo"> + <i :class="pageInfo.icon"></i> + {{ pageInfo.title }} + </template> + </template> + + <MkStickyContainer> + <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> + <router-view v-slot="{ Component }"> + <transition> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/> + </keep-alive> + </transition> + </router-view> + </MkStickyContainer> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XColumn from './column.vue'; +import XNotes from '@/components/notes.vue'; +import { deckStore } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XColumn, + XNotes + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + deckStore, + pageInfo: null, + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + back() { + history.back(); + }, + + 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 (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (window.getSelection().toString() !== '') return; + const path = this.$route.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(path); + } + }], e); + }, + } +}); +</script> diff --git a/packages/client/src/ui/deck/mentions-column.vue b/packages/client/src/ui/deck/mentions-column.vue new file mode 100644 index 0000000000..7dd06989cb --- /dev/null +++ b/packages/client/src/ui/deck/mentions-column.vue @@ -0,0 +1,52 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked"> + <template #header><i class="fas fa-at" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination" @before="before()" @after="after()"/> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import Progress from '@/scripts/loading'; +import XColumn from './column.vue'; +import XNotes from '@/components/notes.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XColumn, + XNotes + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + pagination: { + endpoint: 'notes/mentions', + limit: 10, + }, + } + }, + + methods: { + before() { + Progress.start(); + }, + + after() { + Progress.done(); + } + } +}); +</script> diff --git a/packages/client/src/ui/deck/notifications-column.vue b/packages/client/src/ui/deck/notifications-column.vue new file mode 100644 index 0000000000..f8f406cdd1 --- /dev/null +++ b/packages/client/src/ui/deck/notifications-column.vue @@ -0,0 +1,53 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" :func="{ handler: func, title: $ts.notificationSetting }"> + <template #header><i class="fas fa-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotifications :include-types="column.includingTypes"/> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XColumn from './column.vue'; +import XNotifications from '@/components/notifications.vue'; +import * as os from '@/os'; +import { updateColumn } from './deck-store'; + +export default defineComponent({ + components: { + XColumn, + XNotifications + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + } + }, + + methods: { + func() { + os.popup(import('@/components/notification-setting-window.vue'), { + includingTypes: this.column.includingTypes, + }, { + done: async (res) => { + const { includingTypes } = res; + updateColumn(this.column.id, { + includingTypes: includingTypes + }); + }, + }, 'closed'); + } + } +}); +</script> diff --git a/packages/client/src/ui/deck/tl-column.vue b/packages/client/src/ui/deck/tl-column.vue new file mode 100644 index 0000000000..faf692c447 --- /dev/null +++ b/packages/client/src/ui/deck/tl-column.vue @@ -0,0 +1,137 @@ +<template> +<XColumn :func="{ handler: setType, title: $ts.timeline }" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState"> + <template #header> + <i v-if="column.tl === 'home'" class="fas fa-home"></i> + <i v-else-if="column.tl === 'local'" class="fas fa-comments"></i> + <i v-else-if="column.tl === 'social'" class="fas fa-share-alt"></i> + <i v-else-if="column.tl === 'global'" class="fas fa-globe"></i> + <span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <div class="iwaalbte" v-if="disabled"> + <p> + <i class="fas fa-minus-circle"></i> + {{ $t('disabled-timeline.title') }} + </p> + <p class="desc">{{ $t('disabled-timeline.description') }}</p> + </div> + <XTimeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XColumn from './column.vue'; +import XTimeline from '@/components/timeline.vue'; +import * as os from '@/os'; +import { removeColumn, updateColumn } from './deck-store'; + +export default defineComponent({ + components: { + XColumn, + XTimeline, + }, + + props: { + column: { + type: Object, + required: true + }, + isStacked: { + type: Boolean, + required: true + } + }, + + data() { + return { + disabled: false, + indicated: false, + columnActive: true, + }; + }, + + watch: { + mediaOnly() { + (this.$refs.timeline as any).reload(); + } + }, + + mounted() { + if (this.column.tl == null) { + this.setType(); + } else { + this.disabled = !this.$i.isModerator && !this.$i.isAdmin && ( + this.$instance.disableLocalTimeline && ['local', 'social'].includes(this.column.tl) || + this.$instance.disableGlobalTimeline && ['global'].includes(this.column.tl)); + } + }, + + methods: { + async setType() { + const { canceled, result: src } = await os.dialog({ + title: this.$ts.timeline, + type: null, + select: { + items: [{ + value: 'home', text: this.$ts._timelines.home + }, { + value: 'local', text: this.$ts._timelines.local + }, { + value: 'social', text: this.$ts._timelines.social + }, { + value: 'global', text: this.$ts._timelines.global + }] + }, + }); + if (canceled) { + if (this.column.tl == null) { + removeColumn(this.column.id); + } + return; + } + updateColumn(this.column.id, { + tl: src + }); + }, + + queueUpdated(q) { + if (this.columnActive) { + this.indicated = q !== 0; + } + }, + + onNote() { + if (!this.columnActive) { + this.indicated = true; + } + }, + + onChangeActiveState(state) { + this.columnActive = state; + + if (this.columnActive) { + this.indicated = false; + } + }, + + focus() { + (this.$refs.timeline as any).focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.iwaalbte { + text-align: center; + + > p { + margin: 16px; + + &.desc { + font-size: 14px; + } + } +} +</style> diff --git a/packages/client/src/ui/deck/widgets-column.vue b/packages/client/src/ui/deck/widgets-column.vue new file mode 100644 index 0000000000..8c3a95ac2b --- /dev/null +++ b/packages/client/src/ui/deck/widgets-column.vue @@ -0,0 +1,71 @@ +<template> +<XColumn :func="{ handler: func, title: $ts.editWidgets }" :naked="true" :column="column" :is-stacked="isStacked"> + <template #header><i class="fas fa-window-maximize" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <div class="wtdtxvec"> + <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> + </div> +</XColumn> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/widgets.vue'; +import XColumn from './column.vue'; +import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; + +export default defineComponent({ + components: { + XColumn, + XWidgets, + }, + + props: { + column: { + type: Object, + required: true, + }, + isStacked: { + type: Boolean, + required: true, + }, + }, + + data() { + return { + edit: false, + }; + }, + + methods: { + addWidget(widget) { + addColumnWidget(this.column.id, widget); + }, + + removeWidget(widget) { + removeColumnWidget(this.column.id, widget); + }, + + updateWidget({ id, data }) { + updateColumnWidget(this.column.id, id, data); + }, + + updateWidgets(widgets) { + setColumnWidgets(this.column.id, widgets); + }, + + func() { + this.edit = !this.edit; + } + } +}); +</script> + +<style lang="scss" scoped> +.wtdtxvec { + --margin: 8px; + --panelBorder: none; + + padding: 0 var(--margin); +} +</style> diff --git a/packages/client/src/ui/desktop.vue b/packages/client/src/ui/desktop.vue new file mode 100644 index 0000000000..17783c58e3 --- /dev/null +++ b/packages/client/src/ui/desktop.vue @@ -0,0 +1,70 @@ +<template> +<div class="mk-app" :class="{ wallpaper }" @contextmenu.prevent="() => {}"> + <XSidebar ref="nav" class="sidebar"/> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { host } from '@/config'; +import { search } from '@/scripts/search'; +import XCommon from './_common_/common.vue'; +import * as os from '@/os'; +import XSidebar from '@/ui/_common_/sidebar.vue'; +import { menuDef } from '@/menu'; +import { ColdDeviceStorage } from '@/store'; + +export default defineComponent({ + components: { + XCommon, + XSidebar + }, + + provide() { + return { + navHook: (url) => { + os.pageWindow(url); + } + }; + }, + + data() { + return { + host: host, + menuDef: menuDef, + wallpaper: localStorage.getItem('wallpaper') != null, + }; + }, + + computed: { + menu(): string[] { + return this.$store.state.menu; + }, + }, + + created() { + if (window.innerWidth < 1024) { + localStorage.setItem('ui', 'default'); + location.reload(); + } + }, + + methods: { + help() { + window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + height: 100vh; + width: 100vw; +} +</style> + +<style lang="scss"> +</style> diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue new file mode 100644 index 0000000000..6d00bb6048 --- /dev/null +++ b/packages/client/src/ui/universal.vue @@ -0,0 +1,402 @@ +<template> +<div class="mk-app" :class="{ wallpaper }"> + <XSidebar ref="nav" class="sidebar"/> + + <div class="contents" ref="contents" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }"> + <main ref="main"> + <div class="content"> + <MkStickyContainer> + <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage"/> + </keep-alive> + </transition> + </router-view> + </MkStickyContainer> + </div> + <div class="spacer"></div> + </main> + </div> + + <XSide v-if="isDesktop" class="side" ref="side"/> + + <div v-if="isDesktop" class="widgets" ref="widgets"> + <XWidgets @mounted="attachSticky"/> + </div> + + <div class="buttons" :class="{ navHidden }"> + <button class="button nav _button" @click="showNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> + <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button> + <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> + <button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> + <button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button> + </div> + + <button class="widgetButton _button" :class="{ navHidden }" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> + + <transition name="tray-back"> + <div class="tray-back _modalBg" + v-if="widgetsShowing" + @click="widgetsShowing = false" + @touchstart.passive="widgetsShowing = false" + ></div> + </transition> + + <transition name="tray"> + <XWidgets v-if="widgetsShowing" class="tray"/> + </transition> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { instanceName } from '@/config'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; +import XSidebar from '@/ui/_common_/sidebar.vue'; +import XCommon from './_common_/common.vue'; +import XSide from './classic.side.vue'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import * as symbols from '@/symbols'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XCommon, + XSidebar, + XWidgets: defineAsyncComponent(() => import('./universal.widgets.vue')), + XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる + }, + + provide() { + return { + sideViewHook: this.isDesktop ? (url) => { + this.$refs.side.navigate(url); + } : null + }; + }, + + data() { + return { + pageInfo: null, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + menuDef: menuDef, + navHidden: false, + widgetsShowing: false, + wallpaper: localStorage.getItem('wallpaper') != null, + }; + }, + + computed: { + navIndicated(): boolean { + for (const def in this.menuDef) { + if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから + if (this.menuDef[def].indicated) return true; + } + return false; + } + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + if (this.$store.state.widgets.length === 0) { + this.$store.set('widgets', [{ + name: 'calendar', + id: 'a', place: 'right', data: {} + }, { + name: 'notifications', + id: 'b', place: 'right', data: {} + }, { + name: 'trends', + id: 'c', place: 'right', data: {} + }]); + } + }, + + mounted() { + this.adjustUI(); + + const ro = new ResizeObserver((entries, observer) => { + this.adjustUI(); + }); + + ro.observe(this.$refs.contents); + + window.addEventListener('resize', this.adjustUI, { passive: true }); + + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + document.title = `${this.pageInfo.title} | ${instanceName}`; + } + }, + + adjustUI() { + const navWidth = this.$refs.nav.$el.offsetWidth; + this.navHidden = navWidth === 0; + }, + + showNav() { + this.$refs.nav.show(); + }, + + attachSticky(el) { + const sticky = new StickySidebar(this.$refs.widgets); + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); + }, + + post() { + os.post(); + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + back() { + history.back(); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + 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 (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return; + if (window.getSelection().toString() !== '') return; + const path = this.$route.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: 'fas fa-columns', + text: this.$ts.openInSideView, + action: () => { + this.$refs.side.navigate(path); + } + }, { + icon: 'fas fa-window-maximize', + text: this.$ts.openInWindow, + action: () => { + os.pageWindow(path); + } + }], e); + }, + } +}); +</script> + +<style lang="scss" scoped> +.tray-enter-active, +.tray-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-enter-from, +.tray-leave-active { + opacity: 0; + transform: translateX(240px); +} + +.tray-back-enter-active, +.tray-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-back-enter-from, +.tray-back-leave-active { + opacity: 0; +} + +.mk-app { + $ui-font-size: 1em; // TODO: どこかに集約したい + $widgets-hide-threshold: 1090px; + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + display: flex; + + &.wallpaper { + background: var(--wallpaperOverlay); + //backdrop-filter: var(--blur, blur(4px)); + } + + > .sidebar { + } + + > .contents { + width: 100%; + min-width: 0; + background: var(--panel); + + > main { + min-width: 0; + + > .spacer { + height: 82px; + + @media (min-width: ($widgets-hide-threshold + 1px)) { + display: none; + } + } + } + } + + > .side { + min-width: 370px; + max-width: 370px; + border-left: solid 0.5px var(--divider); + } + + > .widgets { + padding: 0 var(--margin); + border-left: solid 0.5px var(--divider); + background: var(--bg); + + @media (max-width: $widgets-hide-threshold) { + display: none; + } + } + + > .widgetButton { + display: block; + position: fixed; + z-index: 1000; + bottom: 32px; + right: 32px; + width: 64px; + height: 64px; + border-radius: 100%; + box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); + font-size: 22px; + background: var(--panel); + + &.navHidden { + display: none; + } + + @media (min-width: ($widgets-hide-threshold + 1px)) { + display: none; + } + } + + > .buttons { + position: fixed; + z-index: 1000; + bottom: 0; + padding: 16px; + display: flex; + width: 100%; + box-sizing: border-box; + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--header); + + &:not(.navHidden) { + display: none; + } + + > .button { + position: relative; + flex: 1; + padding: 0; + margin: auto; + height: 64px; + border-radius: 8px; + background: var(--panel); + color: var(--fg); + + &:not(:last-child) { + margin-right: 12px; + } + + @media (max-width: 400px) { + height: 60px; + + &:not(:last-child) { + margin-right: 8px; + } + } + + &:hover { + background: var(--X2); + } + + > .indicator { + position: absolute; + top: 0; + left: 0; + color: var(--indicator); + font-size: 16px; + animation: blink 1s infinite; + } + + &:first-child { + margin-left: 0; + } + + &:last-child { + margin-right: 0; + } + + > * { + font-size: 22px; + } + + &:disabled { + cursor: default; + + > * { + opacity: 0.5; + } + } + } + } + + > .tray-back { + z-index: 1001; + } + + > .tray { + position: fixed; + top: 0; + right: 0; + z-index: 1001; + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + height: calc(var(--vh, 1vh) * 100); + padding: var(--margin); + box-sizing: border-box; + overflow: auto; + background: var(--bg); + } +} +</style> + +<style lang="scss"> +</style> diff --git a/packages/client/src/ui/universal.widgets.vue b/packages/client/src/ui/universal.widgets.vue new file mode 100644 index 0000000000..37911d6624 --- /dev/null +++ b/packages/client/src/ui/universal.widgets.vue @@ -0,0 +1,79 @@ +<template> +<div class="efzpzdvf"> + <XWidgets :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> + + <button v-if="editMode" @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button> + <button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import XWidgets from '@/components/widgets.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + XWidgets + }, + + emits: ['mounted'], + + data() { + return { + editMode: false, + }; + }, + + mounted() { + this.$emit('mounted', this.$el); + }, + + methods: { + addWidget(widget) { + this.$store.set('widgets', [{ + ...widget, + place: null, + }, ...this.$store.state.widgets]); + }, + + removeWidget(widget) { + this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id)); + }, + + updateWidget({ id, data }) { + this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? { + ...w, + data: data + } : w)); + }, + + updateWidgets(widgets) { + this.$store.set('widgets', widgets); + } + } +}); +</script> + +<style lang="scss" scoped> +.efzpzdvf { + position: sticky; + height: min-content; + min-height: 100vh; + padding: var(--margin) 0; + box-sizing: border-box; + + > * { + margin: var(--margin) 0; + width: 300px; + + &:first-child { + margin-top: 0; + } + } + + > .add { + margin: 0 auto; + } +} +</style> diff --git a/packages/client/src/ui/visitor.vue b/packages/client/src/ui/visitor.vue new file mode 100644 index 0000000000..ec9150d346 --- /dev/null +++ b/packages/client/src/ui/visitor.vue @@ -0,0 +1,19 @@ +<template> +<DesignB/> +<XCommon/> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import DesignA from './visitor/a.vue'; +import DesignB from './visitor/b.vue'; +import XCommon from './_common_/common.vue'; + +export default defineComponent({ + components: { + XCommon, + DesignA, + DesignB, + }, +}); +</script> diff --git a/packages/client/src/ui/visitor/a.vue b/packages/client/src/ui/visitor/a.vue new file mode 100644 index 0000000000..d7098f94b3 --- /dev/null +++ b/packages/client/src/ui/visitor/a.vue @@ -0,0 +1,260 @@ +<template> +<div class="mk-app"> + <div class="banner" v-if="$route.path === '/'" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div> + <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + <div class="about" v-if="meta"> + <div class="desc" v-html="meta.description || $ts.introMisskey"></div> + </div> + <div class="action"> + <button class="_button primary" @click="signup()">{{ $ts.signup }}</button> + <button class="_button" @click="signin()">{{ $ts.login }}</button> + </div> + </div> + </div> + <div class="banner-mini" v-else :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div> + <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + </div> + </div> + + <div class="main"> + <div class="contents" ref="contents" :class="{ wallpaper }"> + <header class="header" ref="header" v-show="$route.path !== '/'"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <component :is="Component" :ref="changePage"/> + </transition> + </router-view> + </main> + <div class="powered-by"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkButton from '@/components/ui/button.vue'; +import XHeader from './header.vue'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XHeader, + MkPagination, + MkButton, + }, + + data() { + return { + host, + instanceName, + pageInfo: null, + meta: null, + narrow: window.innerWidth < 1280, + announcements: { + endpoint: 'announcements', + limit: 10, + }, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; + this.$store.set('darkMode', !this.$store.state.darkMode); + }, + 's': search, + 'h|/': this.help + }; + }, + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, + + mounted() { + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + setParallax(el) { + //new simpleParallax(el); + }, + + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + min-height: 100vh; + + > .banner { + position: relative; + width: 100%; + text-align: center; + background-position: center; + background-size: cover; + + > div { + height: 100%; + background: rgba(0, 0, 0, 0.3); + + * { + color: #fff; + } + + > h1 { + margin: 0; + padding: 96px 32px 0 32px; + text-shadow: 0 0 8px black; + + > .logo { + vertical-align: bottom; + max-height: 150px; + } + } + + > .about { + padding: 32px; + max-width: 580px; + margin: 0 auto; + box-sizing: border-box; + text-shadow: 0 0 8px black; + } + + > .action { + padding-bottom: 64px; + + > button { + display: inline-block; + padding: 10px 20px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + background: var(--panel); + color: var(--fg); + + &.primary { + background: var(--accent); + color: #fff; + } + + &:first-child { + margin-right: 16px; + } + } + } + } + } + + > .banner-mini { + position: relative; + width: 100%; + text-align: center; + background-position: center; + background-size: cover; + + > div { + position: relative; + z-index: 1; + height: 100%; + background: rgba(0, 0, 0, 0.3); + + * { + color: #fff !important; + } + + > header { + + } + + > h1 { + margin: 0; + padding: 32px; + text-shadow: 0 0 8px black; + + > .logo { + vertical-align: bottom; + max-height: 100px; + } + } + } + } + + > .main { + > .contents { + position: relative; + z-index: 1; + + > .header { + position: sticky; + top: 0; + left: 0; + z-index: 1000; + } + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid var(--divider); + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } +} +</style> + +<style lang="scss"> +</style> diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue new file mode 100644 index 0000000000..d662187aae --- /dev/null +++ b/packages/client/src/ui/visitor/b.vue @@ -0,0 +1,282 @@ +<template> +<div class="mk-app"> + <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a> + + <div class="side" v-if="!narrow && !root"> + <XKanban class="kanban" full/> + </div> + + <div class="main"> + <XKanban class="banner" :powered-by="root" v-if="narrow && !root"/> + + <div class="contents"> + <XHeader class="header" :info="pageInfo" v-if="!root"/> + <main> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <component :is="Component" :ref="changePage"/> + </transition> + </router-view> + </main> + <div class="powered-by" v-if="!root"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> + </div> + </div> + </div> + + <transition name="tray-back"> + <div class="menu-back _modalBg" + v-if="showMenu" + @click="showMenu = false" + @touchstart.passive="showMenu = false" + ></div> + </transition> + + <transition name="tray"> + <div v-if="showMenu" class="menu"> + <MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA> + <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA> + <div class="action"> + <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> + <button class="_button" @click="signin()">{{ $ts.login }}</button> + </div> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/ui/pagination.vue'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XHeader from './header.vue'; +import XKanban from './kanban.vue'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XHeader, + XKanban, + MkPagination, + MkButton, + }, + + data() { + return { + host, + instanceName, + pageInfo: null, + meta: null, + showMenu: false, + narrow: window.innerWidth < 1280, + announcements: { + endpoint: 'announcements', + limit: 10, + }, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; + this.$store.set('darkMode', !this.$store.state.darkMode); + }, + 's': search, + 'h|/': this.help + }; + }, + + root(): boolean { + return this.$route.path === '/'; + }, + }, + + created() { + //document.documentElement.style.overflowY = 'scroll'; + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, + + mounted() { + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + } + } +}); +</script> + +<style> +.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}} +</style> + +<style lang="scss" scoped> +.tray-enter-active, +.tray-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-enter-from, +.tray-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.tray-back-enter-active, +.tray-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-back-enter-from, +.tray-back-leave-active { + opacity: 0; +} + +.mk-app { + display: flex; + min-height: 100vh; + background-position: center; + background-size: cover; + background-attachment: fixed; + + > .side { + width: 500px; + height: 100vh; + + > .kanban { + position: fixed; + top: 0; + left: 0; + width: 500px; + height: 100vh; + overflow: auto; + } + } + + > .main { + flex: 1; + min-width: 0; + + > .banner { + } + + > .contents { + position: relative; + z-index: 1; + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid var(--divider); + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } + + > .menu-back { + position: fixed; + z-index: 1001; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + > .menu { + position: fixed; + z-index: 1001; + top: 0; + left: 0; + width: 240px; + height: 100vh; + background: var(--panel); + + > .link { + display: block; + padding: 16px; + + > .icon { + margin-right: 1em; + } + } + + > .action { + padding: 16px; + + > button { + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + + &._button { + background: var(--panel); + } + + &:first-child { + margin-bottom: 16px; + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/visitor/header.vue b/packages/client/src/ui/visitor/header.vue new file mode 100644 index 0000000000..5caef1cdd6 --- /dev/null +++ b/packages/client/src/ui/visitor/header.vue @@ -0,0 +1,228 @@ +<template> +<div class="sqxihjet"> + <div class="wide" v-if="narrow === false"> + <div class="content"> + <MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA> + <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA> + <div class="page active link" v-if="info"> + <div class="title"> + <i v-if="info.icon" class="icon" :class="info.icon"></i> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> + <span v-if="info.title" class="text">{{ info.title }}</span> + <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> + </div> + <button class="_button action" v-if="info.action" @click.stop="info.action.handler"><!-- TODO --></button> + </div> + <div class="right"> + <button class="_button search" @click="search()"><i class="fas fa-search icon"></i><span>{{ $ts.search }}</span></button> + <button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button> + <button class="_button login" @click="signin()">{{ $ts.login }}</button> + </div> + </div> + </div> + <div class="narrow" v-else-if="narrow === true"> + <button class="menu _button" @click="$parent.showMenu = true"> + <i class="fas fa-bars icon"></i> + </button> + <div class="title" v-if="info"> + <i v-if="info.icon" class="icon" :class="info.icon"></i> + <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> + <span v-if="info.title" class="text">{{ info.title }}</span> + <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/> + </div> + <button class="action _button" v-if="info && info.action" @click.stop="info.action.handler"> + <!-- TODO --> + </button> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import * as os from '@/os'; +import { search } from '@/scripts/search'; + +export default defineComponent({ + props: { + info: { + required: true + }, + }, + + data() { + return { + narrow: null, + showMenu: false, + }; + }, + + mounted() { + this.narrow = this.$el.clientWidth < 1300; + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + }, + + search + } +}); +</script> + +<style lang="scss" scoped> +.sqxihjet { + $height: 60px; + position: sticky; + top: 0; + left: 0; + z-index: 1000; + line-height: $height; + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--X16); + + > .wide { + > .content { + max-width: 1400px; + margin: 0 auto; + display: flex; + align-items: center; + + > .link { + $line: 3px; + display: inline-block; + padding: 0 16px; + line-height: $height - ($line * 2); + border-top: solid $line transparent; + border-bottom: solid $line transparent; + + > .icon { + margin-right: 0.5em; + } + + &.page { + border-bottom-color: var(--accent); + } + } + + > .page { + > .title { + display: inline-block; + vertical-align: bottom; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + + > .icon + .text { + margin-left: 8px; + } + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: middle; + margin-right: 8px; + pointer-events: none; + } + + &._button { + &:hover { + color: var(--fgHighlighted); + } + } + + &.selected { + box-shadow: 0 -2px 0 0 var(--accent) inset; + color: var(--fgHighlighted); + } + } + + > .action { + padding: 0 0 0 16px; + } + } + + > .right { + margin-left: auto; + + > .search { + background: var(--bg); + border-radius: 999px; + width: 230px; + line-height: $height - 20px; + margin-right: 16px; + text-align: left; + + > * { + opacity: 0.7; + } + + > .icon { + padding: 0 16px; + } + } + + > .signup { + border-radius: 999px; + padding: 0 24px; + line-height: $height - 20px; + } + + > .login { + padding: 0 16px; + } + } + } + } + + > .narrow { + display: flex; + + > .menu, + > .action { + width: $height; + height: $height; + font-size: 20px; + } + + > .title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + text-align: center; + + > .icon + .text { + margin-left: 8px; + } + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: middle; + margin-right: 8px; + pointer-events: none; + } + } + } +} +</style> diff --git a/packages/client/src/ui/visitor/kanban.vue b/packages/client/src/ui/visitor/kanban.vue new file mode 100644 index 0000000000..97d210d7e0 --- /dev/null +++ b/packages/client/src/ui/visitor/kanban.vue @@ -0,0 +1,256 @@ +<template> +<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }"> + <div class="back" :class="{ transparent }"></div> + <div class="contents"> + <div class="wrapper"> + <h1 v-if="meta" :class="{ full }"> + <MkA to="/" class="link"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></MkA> + </h1> + <template v-if="full"> + <div class="about" v-if="meta"> + <div class="desc" v-html="meta.description || $ts.introMisskey"></div> + </div> + <div class="action"> + <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button> + <button class="_button" @click="signin()">{{ $ts.login }}</button> + </div> + <div class="announcements panel"> + <header>{{ $ts.announcements }}</header> + <MkPagination :pagination="announcements" #default="{items}" class="list"> + <section class="item" v-for="(announcement, i) in items" :key="announcement.id"> + <div class="title">{{ announcement.title }}</div> + <div class="content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + </div> + </section> + </MkPagination> + </div> + <div class="powered-by" v-if="poweredBy"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> + </div> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import MkPagination from '@/components/ui/pagination.vue'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; + +export default defineComponent({ + components: { + MkPagination, + MkButton, + }, + + props: { + full: { + type: Boolean, + required: false, + default: false, + }, + transparent: { + type: Boolean, + required: false, + default: false, + }, + poweredBy: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + host, + instanceName, + pageInfo: null, + meta: null, + narrow: window.innerWidth < 1280, + announcements: { + endpoint: 'announcements', + limit: 10, + }, + }; + }, + + created() { + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, + + methods: { + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.rwqkcmrc { + position: relative; + text-align: center; + background-position: center; + background-size: cover; + // TODO: パララックスにしたい + + > .back { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.3); + + &.transparent { + -webkit-backdrop-filter: var(--blur, blur(12px)); + backdrop-filter: var(--blur, blur(12px)); + } + } + + > .contents { + position: relative; + z-index: 1; + height: inherit; + overflow: auto; + + > .wrapper { + max-width: 380px; + padding: 0 16px; + box-sizing: border-box; + margin: 0 auto; + + > .panel { + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius); + + &, * { + color: #fff !important; + } + } + + > h1 { + display: block; + margin: 0; + padding: 32px 0 32px 0; + color: #fff; + + &.full { + padding: 64px 0 0 0; + + > .link { + > ::v-deep(.logo) { + max-height: 130px; + } + } + } + + > .link { + display: block; + + > ::v-deep(.logo) { + vertical-align: bottom; + max-height: 100px; + } + } + } + + > .about { + display: block; + margin: 24px 0; + text-align: center; + box-sizing: border-box; + text-shadow: 0 0 8px black; + color: #fff; + } + + > .action { + > button { + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + + &._button { + background: var(--panel); + } + + &:first-child { + margin-bottom: 16px; + } + } + } + + > .announcements { + margin: 32px 0; + text-align: left; + + > header { + padding: 12px 16px; + border-bottom: solid 1px rgba(255, 255, 255, 0.5); + } + + > .list { + max-height: 300px; + overflow: auto; + + > .item { + padding: 12px 16px; + + & + .item { + border-top: solid 1px rgba(255, 255, 255, 0.5); + } + + > .title { + font-weight: bold; + } + + > .content { + > img { + max-width: 100%; + } + } + } + } + } + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid rgba(255, 255, 255, 0.5); + color: #fff; + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } +} +</style> diff --git a/packages/client/src/ui/zen.vue b/packages/client/src/ui/zen.vue new file mode 100644 index 0000000000..7c72232cfd --- /dev/null +++ b/packages/client/src/ui/zen.vue @@ -0,0 +1,106 @@ +<template> +<div class="mk-app"> + <div class="contents"> + <header class="header"> + <MkHeader :info="pageInfo"/> + </header> + <main ref="main"> + <div class="content"> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <keep-alive :include="['timeline']"> + <component :is="Component" :ref="changePage"/> + </keep-alive> + </transition> + </router-view> + </div> + </main> + </div> + + <XCommon/> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { host } from '@/config'; +import XCommon from './_common_/common.vue'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + XCommon, + }, + + data() { + return { + host: host, + pageInfo: null, + }; + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page[symbols.PAGE_INFO]) { + this.pageInfo = page[symbols.PAGE_INFO]; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + $header-height: 52px; + $ui-font-size: 1em; // TODO: どこかに集約したい + + // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc(var(--vh, 1vh) * 100); + box-sizing: border-box; + + > .contents { + padding-top: $header-height; + + > .header { + position: fixed; + z-index: 1000; + top: 0; + height: $header-height; + width: 100%; + line-height: $header-height; + text-align: center; + //background-color: var(--panel); + -webkit-backdrop-filter: var(--blur, blur(32px)); + backdrop-filter: var(--blur, blur(32px)); + background-color: var(--header); + border-bottom: solid 0.5px var(--divider); + } + + > main { + > .content { + > * { + // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + min-height: calc((var(--vh, 1vh) * 100) - #{$header-height}); + } + } + } + } +} +</style> diff --git a/packages/client/src/widgets/activity.calendar.vue b/packages/client/src/widgets/activity.calendar.vue new file mode 100644 index 0000000000..b833bd65ca --- /dev/null +++ b/packages/client/src/widgets/activity.calendar.vue @@ -0,0 +1,85 @@ +<template> +<svg viewBox="0 0 21 7"> + <rect v-for="record in data" class="day" + width="1" height="1" + :x="record.x" :y="record.date.weekday" + rx="1" ry="1" + fill="transparent"> + <title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title> + </rect> + <rect v-for="record in data" class="day" + :width="record.v" :height="record.v" + :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)" + rx="1" ry="1" + :fill="record.color" + style="pointer-events: none;"/> + <rect class="today" + width="1" height="1" + :x="data[0].x" :y="data[0].date.weekday" + rx="1" ry="1" + fill="none" + stroke-width="0.1" + stroke="#f73520"/> +</svg> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +export default defineComponent({ + props: ['data'], + created() { + for (const d of this.data) { + d.total = d.notes + d.replies + d.renotes; + } + const peak = Math.max.apply(null, this.data.map(d => d.total)); + + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + const day = now.getDate(); + + let x = 20; + this.data.slice().forEach((d, i) => { + d.x = x; + + const date = new Date(year, month, day - i); + d.date = { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + weekday: date.getDay() + }; + + d.v = peak === 0 ? 0 : d.total / (peak / 2); + if (d.v > 1) d.v = 1; + const ch = d.date.weekday === 0 || d.date.weekday === 6 ? 275 : 170; + const cs = d.v * 100; + const cl = 15 + ((1 - d.v) * 80); + d.color = `hsl(${ch}, ${cs}%, ${cl}%)`; + + if (d.date.weekday === 0) x--; + }); + } +}); +</script> + +<style lang="scss" scoped> +svg { + display: block; + padding: 16px; + width: 100%; + box-sizing: border-box; + + > rect { + transform-origin: center; + + &.day { + &:hover { + fill: rgba(#000, 0.05); + } + } + } +} +</style> diff --git a/packages/client/src/widgets/activity.chart.vue b/packages/client/src/widgets/activity.chart.vue new file mode 100644 index 0000000000..9702d66663 --- /dev/null +++ b/packages/client/src/widgets/activity.chart.vue @@ -0,0 +1,107 @@ +<template> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown"> + <polyline + :points="pointsNote" + fill="none" + stroke-width="1" + stroke="#41ddde"/> + <polyline + :points="pointsReply" + fill="none" + stroke-width="1" + stroke="#f7796c"/> + <polyline + :points="pointsRenote" + fill="none" + stroke-width="1" + stroke="#a1de41"/> + <polyline + :points="pointsTotal" + fill="none" + stroke-width="1" + stroke="#555" + stroke-dasharray="2 2"/> +</svg> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as os from '@/os'; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); +} + +export default defineComponent({ + props: ['data'], + data() { + return { + viewBoxX: 147, + viewBoxY: 60, + zoom: 1, + pos: 0, + pointsNote: null, + pointsReply: null, + pointsRenote: null, + pointsTotal: null + }; + }, + created() { + for (const d of this.data) { + d.total = d.notes + d.replies + d.renotes; + } + + this.render(); + }, + methods: { + render() { + const peak = Math.max.apply(null, this.data.map(d => d.total)); + if (peak != 0) { + const data = this.data.slice().reverse(); + this.pointsNote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.notes / peak)) * this.viewBoxY}`).join(' '); + this.pointsReply = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '); + this.pointsRenote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.renotes / peak)) * this.viewBoxY}`).join(' '); + this.pointsTotal = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' '); + } + }, + onMousedown(e) { + const clickX = e.clientX; + const clickY = e.clientY; + const baseZoom = this.zoom; + const basePos = this.pos; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - clickX; + let moveTop = me.clientY - clickY; + + this.zoom = baseZoom + (-moveTop / 20); + this.pos = basePos + moveLeft; + if (this.zoom < 1) this.zoom = 1; + if (this.pos > 0) this.pos = 0; + if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX); + + this.render(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +svg { + display: block; + padding: 16px; + width: 100%; + box-sizing: border-box; + cursor: all-scroll; +} +</style> diff --git a/packages/client/src/widgets/activity.vue b/packages/client/src/widgets/activity.vue new file mode 100644 index 0000000000..eaac1455ad --- /dev/null +++ b/packages/client/src/widgets/activity.vue @@ -0,0 +1,82 @@ +<template> +<MkContainer :show-header="props.showHeader" :naked="props.transparent"> + <template #header><i class="fas fa-chart-bar"></i>{{ $ts._widgets.activity }}</template> + <template #func><button @click="toggleView()" class="_button"><i class="fas fa-sort"></i></button></template> + + <div> + <MkLoading v-if="fetching"/> + <template v-else> + <XCalendar v-show="props.view === 0" :data="[].concat(activity)"/> + <XChart v-show="props.view === 1" :data="[].concat(activity)"/> + </template> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import define from './define'; +import XCalendar from './activity.calendar.vue'; +import XChart from './activity.chart.vue'; +import * as os from '@/os'; + +const widget = define({ + name: 'activity', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + transparent: { + type: 'boolean', + default: false, + }, + view: { + type: 'number', + default: 0, + hidden: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, + XCalendar, + XChart, + }, + data() { + return { + fetching: true, + activity: null, + }; + }, + mounted() { + os.api('charts/user/notes', { + userId: this.$i.id, + span: 'day', + limit: 7 * 21 + }).then(activity => { + this.activity = activity.diffs.normal.map((_, i) => ({ + total: activity.diffs.normal[i] + activity.diffs.reply[i] + activity.diffs.renote[i], + notes: activity.diffs.normal[i], + replies: activity.diffs.reply[i], + renotes: activity.diffs.renote[i] + })); + this.fetching = false; + }); + }, + methods: { + toggleView() { + if (this.props.view === 1) { + this.props.view = 0; + } else { + this.props.view++; + } + this.save(); + } + } +}); +</script> diff --git a/packages/client/src/widgets/aichan.vue b/packages/client/src/widgets/aichan.vue new file mode 100644 index 0000000000..8196bff8fa --- /dev/null +++ b/packages/client/src/widgets/aichan.vue @@ -0,0 +1,59 @@ +<template> +<MkContainer :naked="props.transparent" :show-header="false"> + <iframe class="dedjhjmo" ref="live2d" @click="touched" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100"></iframe> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import define from './define'; +import MkContainer from '@/components/ui/container.vue'; +import * as os from '@/os'; + +const widget = define({ + name: 'ai', + props: () => ({ + transparent: { + type: 'boolean', + default: false, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, + }, + data() { + return { + }; + }, + mounted() { + window.addEventListener('mousemove', ev => { + const iframeRect = this.$refs.live2d.getBoundingClientRect(); + this.$refs.live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + } + }, '*'); + }, { passive: true }); + }, + methods: { + touched() { + //if (this.live2d) this.live2d.changeExpression('gurugurume'); + } + } +}); +</script> + +<style lang="scss" scoped> +.dedjhjmo { + width: 100%; + height: 350px; + border: none; + pointer-events: none; +} +</style> diff --git a/packages/client/src/widgets/aiscript.vue b/packages/client/src/widgets/aiscript.vue new file mode 100644 index 0000000000..992ec2f8a1 --- /dev/null +++ b/packages/client/src/widgets/aiscript.vue @@ -0,0 +1,163 @@ +<template> +<MkContainer :show-header="props.showHeader"> + <template #header><i class="fas fa-terminal"></i>{{ $ts._widgets.aiscript }}</template> + + <div class="uylguesu _monospace"> + <textarea v-model="props.script" placeholder="(1 + 1)"></textarea> + <button @click="run" class="_buttonPrimary">RUN</button> + <div class="logs"> + <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import define from './define'; +import * as os from '@/os'; +import { AiScript, parse, utils } from '@syuilo/aiscript'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; + +const widget = define({ + name: 'aiscript', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + script: { + type: 'string', + multiline: true, + default: '(1 + 1)', + hidden: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer + }, + + data() { + return { + logs: [], + }; + }, + + methods: { + async run() { + this.logs = []; + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'widget', + token: this.$i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + this.logs.push({ + id: Math.random(), + text: value.type === 'str' ? value.value : utils.valToString(value), + print: true + }); + }, + log: (type, params) => { + switch (type) { + case 'end': this.logs.push({ + id: Math.random(), + text: utils.valToString(params.val, true), + print: false + }); break; + default: break; + } + } + }); + + let ast; + try { + ast = parse(this.props.script); + } catch (e) { + os.dialog({ + type: 'error', + text: 'Syntax error :(' + }); + return; + } + try { + await aiscript.exec(ast); + } catch (e) { + os.dialog({ + type: 'error', + text: e + }); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.uylguesu { + text-align: right; + + > textarea { + display: block; + width: 100%; + max-width: 100%; + min-width: 100%; + padding: 16px; + color: var(--fg); + background: transparent; + border: none; + border-bottom: solid 0.5px var(--divider); + border-radius: 0; + box-sizing: border-box; + font: inherit; + + &:focus-visible { + outline: none; + } + } + + > button { + display: inline-block; + margin: 8px; + padding: 0 10px; + height: 28px; + outline: none; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + cursor: default; + } + } + + > .logs { + border-top: solid 0.5px var(--divider); + text-align: left; + padding: 16px; + + &:empty { + display: none; + } + + > .log { + &:not(.print) { + opacity: 0.7; + } + } + } +} +</style> diff --git a/packages/client/src/widgets/button.vue b/packages/client/src/widgets/button.vue new file mode 100644 index 0000000000..3417181d0c --- /dev/null +++ b/packages/client/src/widgets/button.vue @@ -0,0 +1,95 @@ +<template> +<div class="mkw-button"> + <MkButton :primary="props.colored" full @click="run"> + {{ props.label }} + </MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import define from './define'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import { AiScript, parse, utils } from '@syuilo/aiscript'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; + +const widget = define({ + name: 'button', + props: () => ({ + label: { + type: 'string', + default: 'BUTTON', + }, + colored: { + type: 'boolean', + default: true, + }, + script: { + type: 'string', + multiline: true, + default: 'Mk:dialog("hello" "world")', + }, + }) +}); + +export default defineComponent({ + components: { + MkButton + }, + extends: widget, + data() { + return { + }; + }, + methods: { + async run() { + const aiscript = new AiScript(createAiScriptEnv({ + storageKey: 'widget', + token: this.$i?.token, + }), { + in: (q) => { + return new Promise(ok => { + os.dialog({ + title: q, + input: {} + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + // nop + }, + log: (type, params) => { + // nop + } + }); + + let ast; + try { + ast = parse(this.props.script); + } catch (e) { + os.dialog({ + type: 'error', + text: 'Syntax error :(' + }); + return; + } + try { + await aiscript.exec(ast); + } catch (e) { + os.dialog({ + type: 'error', + text: e + }); + } + }, + } +}); +</script> + +<style lang="scss" scoped> +.mkw-button { +} +</style> diff --git a/packages/client/src/widgets/calendar.vue b/packages/client/src/widgets/calendar.vue new file mode 100644 index 0000000000..545072e87b --- /dev/null +++ b/packages/client/src/widgets/calendar.vue @@ -0,0 +1,204 @@ +<template> +<div class="mkw-calendar" :class="{ _panel: !props.transparent }"> + <div class="calendar" :class="{ isHoliday }"> + <p class="month-and-year"> + <span class="year">{{ $t('yearX', { year }) }}</span> + <span class="month">{{ $t('monthX', { month }) }}</span> + </p> + <p class="day">{{ $t('dayX', { day }) }}</p> + <p class="week-day">{{ weekDay }}</p> + </div> + <div class="info"> + <div> + <p>{{ $ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${dayP}%` }"></div> + </div> + </div> + <div> + <p>{{ $ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${monthP}%` }"></div> + </div> + </div> + <div> + <p>{{ $ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p> + <div class="meter"> + <div class="val" :style="{ width: `${yearP}%` }"></div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'calendar', + props: () => ({ + transparent: { + type: 'boolean', + default: false, + }, + }) +}); + +export default defineComponent({ + extends: widget, + data() { + return { + now: new Date(), + year: null, + month: null, + day: null, + weekDay: null, + yearP: null, + dayP: null, + monthP: null, + isHoliday: null, + clock: null + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + tick() { + const now = new Date(); + const nd = now.getDate(); + const nm = now.getMonth(); + const ny = now.getFullYear(); + + this.year = ny; + this.month = nm + 1; + this.day = nd; + this.weekDay = [ + this.$ts._weekday.sunday, + this.$ts._weekday.monday, + this.$ts._weekday.tuesday, + this.$ts._weekday.wednesday, + this.$ts._weekday.thursday, + this.$ts._weekday.friday, + this.$ts._weekday.saturday + ][now.getDay()]; + + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); + const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + + this.dayP = dayNumer / dayDenom * 100; + this.monthP = monthNumer / monthDenom * 100; + this.yearP = yearNumer / yearDenom * 100; + + this.isHoliday = now.getDay() === 0 || now.getDay() === 6; + } + } +}); +</script> + +<style lang="scss" scoped> +.mkw-calendar { + padding: 16px 0; + + &:after { + content: ""; + display: block; + clear: both; + } + + > .calendar { + float: left; + width: 60%; + text-align: center; + + &.isHoliday { + > .day { + color: #ef95a0; + } + } + + > p { + margin: 0; + line-height: 18px; + font-size: 0.9em; + + > span { + margin: 0 4px; + } + } + + > .day { + margin: 10px 0; + line-height: 32px; + font-size: 1.75em; + } + } + + > .info { + display: block; + float: left; + width: 40%; + padding: 0 16px 0 0; + box-sizing: border-box; + + > div { + margin-bottom: 8px; + + &:last-child { + margin-bottom: 4px; + } + + > p { + margin: 0 0 2px 0; + font-size: 0.75em; + line-height: 18px; + opacity: 0.8; + + > b { + margin-left: 2px; + } + } + + > .meter { + width: 100%; + overflow: hidden; + background: var(--X11); + border-radius: 8px; + + > .val { + height: 4px; + transition: width .3s cubic-bezier(0.23, 1, 0.32, 1); + } + } + + &:nth-child(1) { + > .meter > .val { + background: #f7796c; + } + } + + &:nth-child(2) { + > .meter > .val { + background: #a1de41; + } + } + + &:nth-child(3) { + > .meter > .val { + background: #41ddde; + } + } + } + } +} +</style> diff --git a/packages/client/src/widgets/clock.vue b/packages/client/src/widgets/clock.vue new file mode 100644 index 0000000000..da0cd65c96 --- /dev/null +++ b/packages/client/src/widgets/clock.vue @@ -0,0 +1,55 @@ +<template> +<MkContainer :naked="props.transparent" :show-header="false"> + <div class="vubelbmv"> + <MkAnalogClock class="clock" :thickness="props.thickness"/> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import define from './define'; +import MkContainer from '@/components/ui/container.vue'; +import MkAnalogClock from '@/components/analog-clock.vue'; +import * as os from '@/os'; + +const widget = define({ + name: 'clock', + props: () => ({ + transparent: { + type: 'boolean', + default: false, + }, + thickness: { + type: 'radio', + default: 0.1, + options: [{ + value: 0.1, label: 'thin' + }, { + value: 0.2, label: 'medium' + }, { + value: 0.3, label: 'thick' + }] + } + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, + MkAnalogClock + }, +}); +</script> + +<style lang="scss" scoped> +.vubelbmv { + padding: 8px; + + > .clock { + height: 150px; + margin: auto; + } +} +</style> diff --git a/packages/client/src/widgets/define.ts b/packages/client/src/widgets/define.ts new file mode 100644 index 0000000000..08a346d97c --- /dev/null +++ b/packages/client/src/widgets/define.ts @@ -0,0 +1,75 @@ +import { defineComponent } from 'vue'; +import { throttle } from 'throttle-debounce'; +import { Form } from '@/scripts/form'; +import * as os from '@/os'; + +export default function <T extends Form>(data: { + name: string; + props?: () => T; +}) { + return defineComponent({ + props: { + widget: { + type: Object, + required: false + }, + settingCallback: { + required: false + } + }, + + emits: ['updateProps'], + + data() { + return { + props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {}, + save: throttle(3000, () => { + this.$emit('updateProps', this.props); + }), + }; + }, + + computed: { + id(): string { + return this.widget ? this.widget.id : null; + }, + }, + + created() { + this.mergeProps(); + + this.$watch('props', () => { + this.mergeProps(); + }, { deep: true }); + + if (this.settingCallback) this.settingCallback(this.setting); + }, + + methods: { + mergeProps() { + if (data.props) { + const defaultProps = data.props(); + for (const prop of Object.keys(defaultProps)) { + if (this.props.hasOwnProperty(prop)) continue; + this.props[prop] = defaultProps[prop].default; + } + } + }, + + async setting() { + const form = data.props(); + for (const item of Object.keys(form)) { + form[item].default = this.props[item]; + } + const { canceled, result } = await os.form(data.name, form); + if (canceled) return; + + for (const key of Object.keys(result)) { + this.props[key] = result[key]; + } + + this.save(); + }, + } + }); +} diff --git a/packages/client/src/widgets/digital-clock.vue b/packages/client/src/widgets/digital-clock.vue new file mode 100644 index 0000000000..9d32e8b9fe --- /dev/null +++ b/packages/client/src/widgets/digital-clock.vue @@ -0,0 +1,79 @@ +<template> +<div class="mkw-digitalClock _monospace" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }"> + <span> + <span v-text="hh"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="mm"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> + <span v-text="ss"></span> + <span :style="{ visibility: showColon ? 'visible' : 'hidden' }" v-if="props.showMs">:</span> + <span v-text="ms" v-if="props.showMs"></span> + </span> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'digitalClock', + props: () => ({ + transparent: { + type: 'boolean', + default: false, + }, + fontSize: { + type: 'number', + default: 1.5, + step: 0.1, + }, + showMs: { + type: 'boolean', + default: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + data() { + return { + clock: null, + hh: null, + mm: null, + ss: null, + ms: null, + showColon: true, + }; + }, + created() { + this.tick(); + this.$watch(() => this.props.showMs, () => { + if (this.clock) clearInterval(this.clock); + this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000); + }, { immediate: true }); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + tick() { + const now = new Date(); + this.hh = now.getHours().toString().padStart(2, '0'); + this.mm = now.getMinutes().toString().padStart(2, '0'); + this.ss = now.getSeconds().toString().padStart(2, '0'); + this.ms = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0'); + this.showColon = now.getSeconds() % 2 === 0; + } + } +}); +</script> + +<style lang="scss" scoped> +.mkw-digitalClock { + padding: 16px 0; + text-align: center; +} +</style> diff --git a/packages/client/src/widgets/federation.vue b/packages/client/src/widgets/federation.vue new file mode 100644 index 0000000000..85cfb8b845 --- /dev/null +++ b/packages/client/src/widgets/federation.vue @@ -0,0 +1,145 @@ +<template> +<MkContainer :show-header="props.showHeader" :foldable="foldable" :scrollable="scrollable"> + <template #header><i class="fas fa-globe"></i>{{ $ts._widgets.federation }}</template> + + <div class="wbrkwalb"> + <MkLoading v-if="fetching"/> + <transition-group tag="div" name="chart" class="instances" v-else> + <div v-for="(instance, i) in instances" :key="instance.id" class="instance"> + <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/> + <div class="body"> + <a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a> + <p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p> + </div> + <MkMiniChart class="chart" :src="charts[i].requests.received"/> + </div> + </transition-group> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import define from './define'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; + +const widget = define({ + name: 'federation', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, MkMiniChart + }, + props: { + foldable: { + type: Boolean, + required: false, + default: false + }, + scrollable: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + instances: [], + charts: [], + fetching: true, + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 1000 * 60); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + async fetch() { + const instances = await os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 5 + }); + const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + this.instances = instances; + this.charts = charts; + this.fetching = false; + } + } +}); +</script> + +<style lang="scss" scoped> +.wbrkwalb { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px; + overflow: hidden; + + > .instances { + .chart-move { + transition: transform 1s ease; + } + + > .instance { + display: flex; + align-items: center; + padding: 14px 16px; + border-bottom: solid 0.5px var(--divider); + + > img { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + object-fit: cover; + border-radius: 4px; + margin-right: 8px; + } + + > .body { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > .a { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > p { + margin: 0; + font-size: 75%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > .chart { + height: 30px; + } + } + } +} +</style> diff --git a/packages/client/src/widgets/index.ts b/packages/client/src/widgets/index.ts new file mode 100644 index 0000000000..51a82af080 --- /dev/null +++ b/packages/client/src/widgets/index.ts @@ -0,0 +1,45 @@ +import { App, defineAsyncComponent } from 'vue'; + +export default function(app: App) { + app.component('MkwMemo', defineAsyncComponent(() => import('./memo.vue'))); + app.component('MkwNotifications', defineAsyncComponent(() => import('./notifications.vue'))); + app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue'))); + app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue'))); + app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue'))); + app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue'))); + app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue'))); + app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue'))); + app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue'))); + app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue'))); + app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue'))); + app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue'))); + app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue'))); + app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue'))); + app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue'))); + app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue'))); + app.component('MkwButton', defineAsyncComponent(() => import('./button.vue'))); + app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue'))); + app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue'))); +} + +export const widgets = [ + 'memo', + 'notifications', + 'timeline', + 'calendar', + 'rss', + 'trends', + 'clock', + 'activity', + 'photos', + 'digitalClock', + 'federation', + 'postForm', + 'slideshow', + 'serverMetric', + 'onlineUsers', + 'jobQueue', + 'button', + 'aiscript', + 'aichan', +]; diff --git a/packages/client/src/widgets/job-queue.vue b/packages/client/src/widgets/job-queue.vue new file mode 100644 index 0000000000..ef440881e5 --- /dev/null +++ b/packages/client/src/widgets/job-queue.vue @@ -0,0 +1,183 @@ +<template> +<div class="mkw-jobQueue _monospace" :class="{ _panel: !props.transparent }"> + <div class="inbox"> + <div class="label">Inbox queue<i v-if="inbox.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div> + <div class="values"> + <div> + <div>Process</div> + <div :class="{ inc: inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(inbox.activeSincePrevTick) }}</div> + </div> + <div> + <div>Active</div> + <div :class="{ inc: inbox.active > prev.inbox.active, dec: inbox.active < prev.inbox.active }">{{ number(inbox.active) }}</div> + </div> + <div> + <div>Delayed</div> + <div :class="{ inc: inbox.delayed > prev.inbox.delayed, dec: inbox.delayed < prev.inbox.delayed }">{{ number(inbox.delayed) }}</div> + </div> + <div> + <div>Waiting</div> + <div :class="{ inc: inbox.waiting > prev.inbox.waiting, dec: inbox.waiting < prev.inbox.waiting }">{{ number(inbox.waiting) }}</div> + </div> + </div> + </div> + <div class="deliver"> + <div class="label">Deliver queue<i v-if="deliver.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div> + <div class="values"> + <div> + <div>Process</div> + <div :class="{ inc: deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(deliver.activeSincePrevTick) }}</div> + </div> + <div> + <div>Active</div> + <div :class="{ inc: deliver.active > prev.deliver.active, dec: deliver.active < prev.deliver.active }">{{ number(deliver.active) }}</div> + </div> + <div> + <div>Delayed</div> + <div :class="{ inc: deliver.delayed > prev.deliver.delayed, dec: deliver.delayed < prev.deliver.delayed }">{{ number(deliver.delayed) }}</div> + </div> + <div> + <div>Waiting</div> + <div :class="{ inc: deliver.waiting > prev.deliver.waiting, dec: deliver.waiting < prev.deliver.waiting }">{{ number(deliver.waiting) }}</div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import define from './define'; +import * as os from '@/os'; +import number from '@/filters/number'; +import * as sound from '@/scripts/sound'; + +const widget = define({ + name: 'jobQueue', + props: () => ({ + transparent: { + type: 'boolean', + default: false, + }, + sound: { + type: 'boolean', + default: false, + }, + }) +}); + +export default defineComponent({ + extends: widget, + data() { + return { + connection: markRaw(os.stream.useChannel('queueStats')), + inbox: { + activeSincePrevTick: 0, + active: 0, + waiting: 0, + delayed: 0, + }, + deliver: { + activeSincePrevTick: 0, + active: 0, + waiting: 0, + delayed: 0, + }, + prev: {}, + sound: sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1) + }; + }, + created() { + for (const domain of ['inbox', 'deliver']) { + this.prev[domain] = JSON.parse(JSON.stringify(this[domain])); + } + + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 1 + }); + }, + beforeUnmount() { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + this.connection.dispose(); + }, + methods: { + onStats(stats) { + for (const domain of ['inbox', 'deliver']) { + this.prev[domain] = JSON.parse(JSON.stringify(this[domain])); + this[domain].activeSincePrevTick = stats[domain].activeSincePrevTick; + this[domain].active = stats[domain].active; + this[domain].waiting = stats[domain].waiting; + this[domain].delayed = stats[domain].delayed; + + if (this[domain].waiting > 0 && this.props.sound && this.sound.paused) { + this.sound.play(); + } + } + }, + + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + }, + + number + } +}); +</script> + +<style lang="scss" scoped> +@keyframes warnBlink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +.mkw-jobQueue { + font-size: 0.9em; + + > div { + padding: 16px; + + &:not(:first-child) { + border-top: solid 0.5px var(--divider); + } + + > .label { + display: flex; + + > .icon { + color: var(--warn); + margin-left: auto; + animation: warnBlink 1s infinite; + } + } + + > .values { + display: flex; + + > div { + flex: 1; + + > div:first-child { + opacity: 0.7; + } + + > div:last-child { + &.inc { + color: var(--warn); + } + + &.dec { + color: var(--success); + } + } + } + } + } +} +</style> diff --git a/packages/client/src/widgets/memo.vue b/packages/client/src/widgets/memo.vue new file mode 100644 index 0000000000..100f1b2934 --- /dev/null +++ b/packages/client/src/widgets/memo.vue @@ -0,0 +1,106 @@ +<template> +<MkContainer :show-header="props.showHeader"> + <template #header><i class="fas fa-sticky-note"></i>{{ $ts._widgets.memo }}</template> + + <div class="otgbylcu"> + <textarea v-model="text" :placeholder="$ts.placeholder" @input="onChange"></textarea> + <button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $ts.save }}</button> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'memo', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer + }, + + data() { + return { + text: null, + changed: false, + timeoutId: null, + }; + }, + + created() { + this.text = this.$store.state.memo; + + this.$watch(() => this.$store.reactiveState.memo, text => { + this.text = text; + }); + }, + + methods: { + onChange() { + this.changed = true; + clearTimeout(this.timeoutId); + this.timeoutId = setTimeout(this.saveMemo, 1000); + }, + + saveMemo() { + this.$store.set('memo', this.text); + this.changed = false; + } + } +}); +</script> + +<style lang="scss" scoped> +.otgbylcu { + padding-bottom: 28px + 16px; + + > textarea { + display: block; + width: 100%; + max-width: 100%; + min-width: 100%; + padding: 16px; + color: var(--fg); + background: transparent; + border: none; + border-bottom: solid 0.5px var(--divider); + border-radius: 0; + box-sizing: border-box; + font: inherit; + font-size: 0.9em; + + &:focus-visible { + outline: none; + } + } + + > button { + display: block; + position: absolute; + bottom: 8px; + right: 8px; + margin: 0; + padding: 0 10px; + height: 28px; + outline: none; + border-radius: 4px; + + &:disabled { + opacity: 0.7; + cursor: default; + } + } +} +</style> diff --git a/packages/client/src/widgets/notifications.vue b/packages/client/src/widgets/notifications.vue new file mode 100644 index 0000000000..462f39a339 --- /dev/null +++ b/packages/client/src/widgets/notifications.vue @@ -0,0 +1,65 @@ +<template> +<MkContainer :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true"> + <template #header><i class="fas fa-bell"></i>{{ $ts.notifications }}</template> + <template #func><button @click="configure()" class="_button"><i class="fas fa-cog"></i></button></template> + + <div> + <XNotifications :include-types="props.includingTypes"/> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import XNotifications from '@/components/notifications.vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'notifications', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + height: { + type: 'number', + default: 300, + }, + includingTypes: { + type: 'array', + hidden: true, + default: null, + }, + }) +}); + +export default defineComponent({ + extends: widget, + + components: { + MkContainer, + XNotifications, + }, + + data() { + return { + }; + }, + + methods: { + configure() { + os.popup(import('@/components/notification-setting-window.vue'), { + includingTypes: this.props.includingTypes, + }, { + done: async (res) => { + const { includingTypes } = res; + this.props.includingTypes = includingTypes; + this.save(); + } + }, 'closed'); + } + } +}); +</script> diff --git a/packages/client/src/widgets/online-users.vue b/packages/client/src/widgets/online-users.vue new file mode 100644 index 0000000000..5b889f4816 --- /dev/null +++ b/packages/client/src/widgets/online-users.vue @@ -0,0 +1,67 @@ +<template> +<div class="mkw-onlineUsers" :class="{ _panel: !props.transparent, pad: !props.transparent }"> + <I18n v-if="onlineUsersCount" :src="$ts.onlineUsersCount" text-tag="span" class="text"> + <template #n><b>{{ onlineUsersCount }}</b></template> + </I18n> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'onlineUsers', + props: () => ({ + transparent: { + type: 'boolean', + default: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + data() { + return { + onlineUsersCount: null, + clock: null, + }; + }, + created() { + this.tick(); + this.clock = setInterval(this.tick, 1000 * 15); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + tick() { + os.api('get-online-users-count').then(res => { + this.onlineUsersCount = res.count; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mkw-onlineUsers { + text-align: center; + + &.pad { + padding: 16px 0; + } + + > .text { + ::v-deep(b) { + color: #41b781; + } + + ::v-deep(span) { + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/widgets/photos.vue b/packages/client/src/widgets/photos.vue new file mode 100644 index 0000000000..0c919526b0 --- /dev/null +++ b/packages/client/src/widgets/photos.vue @@ -0,0 +1,113 @@ +<template> +<MkContainer :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent ? true : null"> + <template #header><i class="fas fa-camera"></i>{{ $ts._widgets.photos }}</template> + + <div class=""> + <MkLoading v-if="fetching"/> + <div v-else :class="$style.stream"> + <div v-for="(image, i) in images" :key="i" + :class="$style.img" + :style="`background-image: url(${thumbnail(image)})`" + ></div> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import define from './define'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import * as os from '@/os'; + +const widget = define({ + name: 'photos', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + transparent: { + type: 'boolean', + default: false, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, + }, + data() { + return { + images: [], + fetching: true, + connection: null, + }; + }, + mounted() { + this.connection = markRaw(os.stream.useChannel('main')); + + this.connection.on('driveFileCreated', this.onDriveFileCreated); + + os.api('drive/stream', { + type: 'image/*', + limit: 9 + }).then(images => { + this.images = images; + this.fetching = false; + }); + }, + beforeUnmount() { + this.connection.dispose(); + }, + methods: { + onDriveFileCreated(file) { + if (/^image\/.+$/.test(file.type)) { + this.images.unshift(file); + if (this.images.length > 9) this.images.pop(); + } + }, + + thumbnail(image: any): string { + return this.$store.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; + }, + } +}); +</script> + +<style lang="scss" module> +.root[data-transparent] { + .stream { + padding: 0; + } + + .img { + border: solid 4px transparent; + border-radius: 8px; + } +} + +.stream { + display: flex; + justify-content: center; + flex-wrap: wrap; + padding: 8px; + + .img { + flex: 1 1 33%; + width: 33%; + height: 80px; + box-sizing: border-box; + background-position: center center; + background-size: cover; + background-clip: content-box; + border: solid 2px transparent; + border-radius: 4px; + } +} +</style> diff --git a/packages/client/src/widgets/post-form.vue b/packages/client/src/widgets/post-form.vue new file mode 100644 index 0000000000..5ecaa67b5a --- /dev/null +++ b/packages/client/src/widgets/post-form.vue @@ -0,0 +1,23 @@ +<template> +<XPostForm class="_panel" :fixed="true" :autofocus="false"/> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPostForm from '@/components/post-form.vue'; +import define from './define'; + +const widget = define({ + name: 'postForm', + props: () => ({ + }) +}); + +export default defineComponent({ + extends: widget, + + components: { + XPostForm, + }, +}); +</script> diff --git a/packages/client/src/widgets/rss.vue b/packages/client/src/widgets/rss.vue new file mode 100644 index 0000000000..235fce574a --- /dev/null +++ b/packages/client/src/widgets/rss.vue @@ -0,0 +1,89 @@ +<template> +<MkContainer :show-header="props.showHeader"> + <template #header><i class="fas fa-rss-square"></i>RSS</template> + <template #func><button class="_button" @click="setting"><i class="fas fa-cog"></i></button></template> + + <div class="ekmkgxbj"> + <MkLoading v-if="fetching"/> + <div class="feed" v-else> + <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'rss', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + url: { + type: 'string', + default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer + }, + data() { + return { + items: [], + fetching: true, + clock: null, + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 60000); + this.$watch(() => this.props.url, this.fetch); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + fetch() { + fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, { + }).then(res => { + res.json().then(feed => { + this.items = feed.items; + this.fetching = false; + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.ekmkgxbj { + > .feed { + padding: 0; + font-size: 0.9em; + + > a { + display: block; + padding: 8px 16px; + color: var(--fg); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + &:nth-child(even) { + background: rgba(#000, 0.05); + } + } + } +} +</style> diff --git a/packages/client/src/widgets/server-metric/cpu-mem.vue b/packages/client/src/widgets/server-metric/cpu-mem.vue new file mode 100644 index 0000000000..ad9e6a8b0f --- /dev/null +++ b/packages/client/src/widgets/server-metric/cpu-mem.vue @@ -0,0 +1,174 @@ +<template> +<div class="lcfyofjk"> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <defs> + <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="cpuPolygonPoints" + fill="#fff" + fill-opacity="0.5" + /> + <polyline + :points="cpuPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1" + /> + <circle + :cx="cpuHeadX" + :cy="cpuHeadY" + r="1.5" + fill="#fff" + /> + </mask> + </defs> + <rect + x="-2" y="-2" + :width="viewBoxX + 4" :height="viewBoxY + 4" + :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`" + /> + <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text> + </svg> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <defs> + <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0"> + <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop> + <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop> + </linearGradient> + <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> + <polygon + :points="memPolygonPoints" + fill="#fff" + fill-opacity="0.5" + /> + <polyline + :points="memPolylinePoints" + fill="none" + stroke="#fff" + stroke-width="1" + /> + <circle + :cx="memHeadX" + :cy="memHeadY" + r="1.5" + fill="#fff" + /> + </mask> + </defs> + <rect + x="-2" y="-2" + :width="viewBoxX + 4" :height="viewBoxY + 4" + :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`" + /> + <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text> + </svg> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; + +export default defineComponent({ + props: { + connection: { + required: true, + }, + meta: { + required: true, + } + }, + data() { + return { + viewBoxX: 50, + viewBoxY: 30, + stats: [], + cpuGradientId: uuid(), + cpuMaskId: uuid(), + memGradientId: uuid(), + memMaskId: uuid(), + cpuPolylinePoints: '', + memPolylinePoints: '', + cpuPolygonPoints: '', + memPolygonPoints: '', + cpuHeadX: null, + cpuHeadY: null, + memHeadX: null, + memHeadY: null, + cpuP: '', + memP: '' + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8) + }); + }, + beforeUnmount() { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + }, + methods: { + onStats(stats) { + this.stats.push(stats); + if (this.stats.length > 50) this.stats.shift(); + + const cpuPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - s.cpu) * this.viewBoxY]); + const memPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.mem.active / this.meta.mem.total)) * this.viewBoxY]); + this.cpuPolylinePoints = cpuPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + this.memPolylinePoints = memPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + + this.cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.cpuPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`; + this.memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.memPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`; + + this.cpuHeadX = cpuPolylinePoints[cpuPolylinePoints.length - 1][0]; + this.cpuHeadY = cpuPolylinePoints[cpuPolylinePoints.length - 1][1]; + this.memHeadX = memPolylinePoints[memPolylinePoints.length - 1][0]; + this.memHeadY = memPolylinePoints[memPolylinePoints.length - 1][1]; + + this.cpuP = (stats.cpu * 100).toFixed(0); + this.memP = (stats.mem.active / this.meta.mem.total * 100).toFixed(0); + }, + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + } + } +}); +</script> + +<style lang="scss" scoped> +.lcfyofjk { + display: flex; + + > svg { + display: block; + padding: 10px; + width: 50%; + + &:first-child { + padding-right: 5px; + } + + &:last-child { + padding-left: 5px; + } + + > text { + font-size: 5px; + fill: currentColor; + + > tspan { + opacity: 0.5; + } + } + } +} +</style> diff --git a/packages/client/src/widgets/server-metric/cpu.vue b/packages/client/src/widgets/server-metric/cpu.vue new file mode 100644 index 0000000000..4478ee3065 --- /dev/null +++ b/packages/client/src/widgets/server-metric/cpu.vue @@ -0,0 +1,76 @@ +<template> +<div class="vrvdvrys"> + <XPie class="pie" :value="usage"/> + <div> + <p><i class="fas fa-microchip"></i>CPU</p> + <p>{{ meta.cpu.cores }} Logical cores</p> + <p>{{ meta.cpu.model }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPie from './pie.vue'; + +export default defineComponent({ + components: { + XPie + }, + props: { + connection: { + required: true, + }, + meta: { + required: true, + } + }, + data() { + return { + usage: 0, + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeUnmount() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.usage = stats.cpu; + } + } +}); +</script> + +<style lang="scss" scoped> +.vrvdvrys { + display: flex; + padding: 16px; + + > .pie { + height: 82px; + flex-shrink: 0; + margin-right: 16px; + } + + > div { + flex: 1; + + > p { + margin: 0; + font-size: 0.8em; + + &:first-child { + font-weight: bold; + margin-bottom: 4px; + + > i { + margin-right: 4px; + } + } + } + } +} +</style> diff --git a/packages/client/src/widgets/server-metric/disk.vue b/packages/client/src/widgets/server-metric/disk.vue new file mode 100644 index 0000000000..650101b0ee --- /dev/null +++ b/packages/client/src/widgets/server-metric/disk.vue @@ -0,0 +1,70 @@ +<template> +<div class="zbwaqsat"> + <XPie class="pie" :value="usage"/> + <div> + <p><i class="fas fa-hdd"></i>Disk</p> + <p>Total: {{ bytes(total, 1) }}</p> + <p>Free: {{ bytes(available, 1) }}</p> + <p>Used: {{ bytes(used, 1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPie from './pie.vue'; +import bytes from '@/filters/bytes'; + +export default defineComponent({ + components: { + XPie + }, + props: { + meta: { + required: true, + } + }, + data() { + return { + usage: this.meta.fs.used / this.meta.fs.total, + total: this.meta.fs.total, + used: this.meta.fs.used, + available: this.meta.fs.total - this.meta.fs.used, + }; + }, + methods: { + bytes + } +}); +</script> + +<style lang="scss" scoped> +.zbwaqsat { + display: flex; + padding: 16px; + + > .pie { + height: 82px; + flex-shrink: 0; + margin-right: 16px; + } + + > div { + flex: 1; + + > p { + margin: 0; + font-size: 0.8em; + + &:first-child { + font-weight: bold; + margin-bottom: 4px; + + > i { + margin-right: 4px; + } + } + } + } +} +</style> diff --git a/packages/client/src/widgets/server-metric/index.vue b/packages/client/src/widgets/server-metric/index.vue new file mode 100644 index 0000000000..cfe3c15df7 --- /dev/null +++ b/packages/client/src/widgets/server-metric/index.vue @@ -0,0 +1,82 @@ +<template> +<MkContainer :show-header="props.showHeader" :naked="props.transparent"> + <template #header><i class="fas fa-server"></i>{{ $ts._widgets.serverMetric }}</template> + <template #func><button @click="toggleView()" class="_button"><i class="fas fa-sort"></i></button></template> + + <div class="mkw-serverMetric" v-if="meta"> + <XCpuMemory v-if="props.view === 0" :connection="connection" :meta="meta"/> + <XNet v-if="props.view === 1" :connection="connection" :meta="meta"/> + <XCpu v-if="props.view === 2" :connection="connection" :meta="meta"/> + <XMemory v-if="props.view === 3" :connection="connection" :meta="meta"/> + <XDisk v-if="props.view === 4" :connection="connection" :meta="meta"/> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent, markRaw } from 'vue'; +import define from '../define'; +import MkContainer from '@/components/ui/container.vue'; +import XCpuMemory from './cpu-mem.vue'; +import XNet from './net.vue'; +import XCpu from './cpu.vue'; +import XMemory from './mem.vue'; +import XDisk from './disk.vue'; +import * as os from '@/os'; + +const widget = define({ + name: 'serverMetric', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + transparent: { + type: 'boolean', + default: false, + }, + view: { + type: 'number', + default: 0, + hidden: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, + XCpuMemory, + XNet, + XCpu, + XMemory, + XDisk, + }, + data() { + return { + meta: null, + connection: null, + }; + }, + created() { + os.api('server-info', {}).then(res => { + this.meta = res; + }); + this.connection = markRaw(os.stream.useChannel('serverStats')); + }, + unmounted() { + this.connection.dispose(); + }, + methods: { + toggleView() { + if (this.props.view == 4) { + this.props.view = 0; + } else { + this.props.view++; + } + this.save(); + }, + } +}); +</script> diff --git a/packages/client/src/widgets/server-metric/mem.vue b/packages/client/src/widgets/server-metric/mem.vue new file mode 100644 index 0000000000..a6ca7b1175 --- /dev/null +++ b/packages/client/src/widgets/server-metric/mem.vue @@ -0,0 +1,85 @@ +<template> +<div class="zlxnikvl"> + <XPie class="pie" :value="usage"/> + <div> + <p><i class="fas fa-memory"></i>RAM</p> + <p>Total: {{ bytes(total, 1) }}</p> + <p>Used: {{ bytes(used, 1) }}</p> + <p>Free: {{ bytes(free, 1) }}</p> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XPie from './pie.vue'; +import bytes from '@/filters/bytes'; + +export default defineComponent({ + components: { + XPie + }, + props: { + connection: { + required: true, + }, + meta: { + required: true, + } + }, + data() { + return { + usage: 0, + total: 0, + used: 0, + free: 0, + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + }, + beforeUnmount() { + this.connection.off('stats', this.onStats); + }, + methods: { + onStats(stats) { + this.usage = stats.mem.active / this.meta.mem.total; + this.total = this.meta.mem.total; + this.used = stats.mem.active; + this.free = this.meta.mem.total - stats.mem.active; + }, + bytes + } +}); +</script> + +<style lang="scss" scoped> +.zlxnikvl { + display: flex; + padding: 16px; + + > .pie { + height: 82px; + flex-shrink: 0; + margin-right: 16px; + } + + > div { + flex: 1; + + > p { + margin: 0; + font-size: 0.8em; + + &:first-child { + font-weight: bold; + margin-bottom: 4px; + + > i { + margin-right: 4px; + } + } + } + } +} +</style> diff --git a/packages/client/src/widgets/server-metric/net.vue b/packages/client/src/widgets/server-metric/net.vue new file mode 100644 index 0000000000..23c148eeb6 --- /dev/null +++ b/packages/client/src/widgets/server-metric/net.vue @@ -0,0 +1,148 @@ +<template> +<div class="oxxrhrto"> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <polygon + :points="inPolygonPoints" + fill="#94a029" + fill-opacity="0.5" + /> + <polyline + :points="inPolylinePoints" + fill="none" + stroke="#94a029" + stroke-width="1" + /> + <circle + :cx="inHeadX" + :cy="inHeadY" + r="1.5" + fill="#94a029" + /> + <text x="1" y="5">NET rx <tspan>{{ bytes(inRecent) }}</tspan></text> + </svg> + <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`"> + <polygon + :points="outPolygonPoints" + fill="#ff9156" + fill-opacity="0.5" + /> + <polyline + :points="outPolylinePoints" + fill="none" + stroke="#ff9156" + stroke-width="1" + /> + <circle + :cx="outHeadX" + :cy="outHeadY" + r="1.5" + fill="#ff9156" + /> + <text x="1" y="5">NET tx <tspan>{{ bytes(outRecent) }}</tspan></text> + </svg> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import bytes from '@/filters/bytes'; + +export default defineComponent({ + props: { + connection: { + required: true, + }, + meta: { + required: true, + } + }, + data() { + return { + viewBoxX: 50, + viewBoxY: 30, + stats: [], + inPolylinePoints: '', + outPolylinePoints: '', + inPolygonPoints: '', + outPolygonPoints: '', + inHeadX: null, + inHeadY: null, + outHeadX: null, + outHeadY: null, + inRecent: 0, + outRecent: 0 + }; + }, + mounted() { + this.connection.on('stats', this.onStats); + this.connection.on('statsLog', this.onStatsLog); + this.connection.send('requestLog', { + id: Math.random().toString().substr(2, 8) + }); + }, + beforeUnmount() { + this.connection.off('stats', this.onStats); + this.connection.off('statsLog', this.onStatsLog); + }, + methods: { + onStats(stats) { + this.stats.push(stats); + if (this.stats.length > 50) this.stats.shift(); + + const inPeak = Math.max(1024 * 64, Math.max(...this.stats.map(s => s.net.rx))); + const outPeak = Math.max(1024 * 64, Math.max(...this.stats.map(s => s.net.tx))); + + const inPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.net.rx / inPeak)) * this.viewBoxY]); + const outPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.net.tx / outPeak)) * this.viewBoxY]); + this.inPolylinePoints = inPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + this.outPolylinePoints = outPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + + this.inPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.inPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`; + this.outPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.outPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`; + + this.inHeadX = inPolylinePoints[inPolylinePoints.length - 1][0]; + this.inHeadY = inPolylinePoints[inPolylinePoints.length - 1][1]; + this.outHeadX = outPolylinePoints[outPolylinePoints.length - 1][0]; + this.outHeadY = outPolylinePoints[outPolylinePoints.length - 1][1]; + + this.inRecent = stats.net.rx; + this.outRecent = stats.net.tx; + }, + onStatsLog(statsLog) { + for (const stats of [...statsLog].reverse()) { + this.onStats(stats); + } + }, + bytes + } +}); +</script> + +<style lang="scss" scoped> +.oxxrhrto { + display: flex; + + > svg { + display: block; + padding: 10px; + width: 50%; + + &:first-child { + padding-right: 5px; + } + + &:last-child { + padding-left: 5px; + } + + > text { + font-size: 5px; + fill: currentColor; + + > tspan { + opacity: 0.5; + } + } + } +} +</style> diff --git a/packages/client/src/widgets/server-metric/pie.vue b/packages/client/src/widgets/server-metric/pie.vue new file mode 100644 index 0000000000..38dcf6fcd9 --- /dev/null +++ b/packages/client/src/widgets/server-metric/pie.vue @@ -0,0 +1,65 @@ +<template> +<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none"> + <circle + :r="r" + cx="50%" cy="50%" + fill="none" + stroke-width="0.1" + stroke="rgba(0, 0, 0, 0.05)" + /> + <circle + :r="r" + cx="50%" cy="50%" + :stroke-dasharray="Math.PI * (r * 2)" + :stroke-dashoffset="strokeDashoffset" + fill="none" + stroke-width="0.1" + :stroke="color" + /> + <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text> +</svg> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: true + } + }, + data() { + return { + r: 0.45 + }; + }, + computed: { + color(): string { + return `hsl(${180 - (this.value * 180)}, 80%, 70%)`; + }, + strokeDashoffset(): number { + return (1 - this.value) * (Math.PI * (this.r * 2)); + } + } +}); +</script> + +<style lang="scss" scoped> +.hsalcinq { + display: block; + height: 100%; + + > circle { + transform-origin: center; + transform: rotate(-90deg); + transition: stroke-dashoffset 0.5s ease; + } + + > text { + font-size: 0.15px; + fill: currentColor; + } +} +</style> diff --git a/packages/client/src/widgets/slideshow.vue b/packages/client/src/widgets/slideshow.vue new file mode 100644 index 0000000000..0909bda67c --- /dev/null +++ b/packages/client/src/widgets/slideshow.vue @@ -0,0 +1,167 @@ +<template> +<div class="kvausudm _panel"> + <div @click="choose"> + <p v-if="props.folderId == null"> + <template v-if="isCustomizeMode">{{ $t('folder-customize-mode') }}</template> + <template v-else>{{ $ts.folder }}</template> + </p> + <p v-if="props.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p> + <div ref="slideA" class="slide a"></div> + <div ref="slideB" class="slide b"></div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'slideshow', + props: () => ({ + height: { + type: 'number', + default: 300, + }, + folderId: { + type: 'string', + default: null, + hidden: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + data() { + return { + images: [], + fetching: true, + clock: null + }; + }, + mounted() { + this.$nextTick(() => { + this.applySize(); + }); + + if (this.props.folderId != null) { + this.fetch(); + } + + this.clock = setInterval(this.change, 10000); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + applySize() { + let h; + + if (this.props.size == 1) { + h = 250; + } else { + h = 170; + } + + this.$el.style.height = `${h}px`; + }, + resize() { + if (this.props.size == 1) { + this.props.size = 0; + } else { + this.props.size++; + } + this.save(); + + this.applySize(); + }, + change() { + if (this.images.length == 0) return; + + const index = Math.floor(Math.random() * this.images.length); + const img = `url(${ this.images[index].url })`; + + (this.$refs.slideB as any).style.backgroundImage = img; + + this.$refs.slideB.classList.add('anime'); + setTimeout(() => { + // 既にこのウィジェットがunmountされていたら要素がない + if ((this.$refs.slideA as any) == null) return; + + (this.$refs.slideA as any).style.backgroundImage = img; + + this.$refs.slideB.classList.remove('anime'); + }, 1000); + }, + fetch() { + this.fetching = true; + + os.api('drive/files', { + folderId: this.props.folderId, + type: 'image/*', + limit: 100 + }).then(images => { + this.images = images; + this.fetching = false; + (this.$refs.slideA as any).style.backgroundImage = ''; + (this.$refs.slideB as any).style.backgroundImage = ''; + this.change(); + }); + }, + choose() { + os.selectDriveFolder(false).then(folder => { + if (folder == null) { + return; + } + this.props.folderId = folder.id; + this.save(); + this.fetch(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.kvausudm { + position: relative; + + > div { + width: 100%; + height: 100%; + cursor: pointer; + + > p { + display: block; + margin: 1em; + text-align: center; + color: #888; + } + + > * { + pointer-events: none; + } + + > .slide { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + + &.b { + opacity: 0; + } + + &.anime { + transition: opacity 1s; + opacity: 1; + } + } + } +} +</style> diff --git a/packages/client/src/widgets/timeline.vue b/packages/client/src/widgets/timeline.vue new file mode 100644 index 0000000000..0d0629abe2 --- /dev/null +++ b/packages/client/src/widgets/timeline.vue @@ -0,0 +1,116 @@ +<template> +<MkContainer :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true"> + <template #header> + <button @click="choose" class="_button"> + <i v-if="props.src === 'home'" class="fas fa-home"></i> + <i v-else-if="props.src === 'local'" class="fas fa-comments"></i> + <i v-else-if="props.src === 'social'" class="fas fa-share-alt"></i> + <i v-else-if="props.src === 'global'" class="fas fa-globe"></i> + <i v-else-if="props.src === 'list'" class="fas fa-list-ul"></i> + <i v-else-if="props.src === 'antenna'" class="fas fa-satellite"></i> + <span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span> + <i :class="menuOpened ? 'fas fa-angle-up' : 'fas fa-angle-down'" style="margin-left: 8px;"></i> + </button> + </template> + + <div> + <XTimeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import XTimeline from '@/components/timeline.vue'; +import define from './define'; +import * as os from '@/os'; + +const widget = define({ + name: 'timeline', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + height: { + type: 'number', + default: 300, + }, + src: { + type: 'string', + default: 'home', + hidden: true, + }, + list: { + type: 'object', + default: null, + hidden: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, + XTimeline, + }, + + data() { + return { + menuOpened: false, + }; + }, + + methods: { + async choose(ev) { + this.menuOpened = true; + const [antennas, lists] = await Promise.all([ + os.api('antennas/list'), + os.api('users/lists/list') + ]); + const antennaItems = antennas.map(antenna => ({ + text: antenna.name, + icon: 'fas fa-satellite', + action: () => { + this.props.antenna = antenna; + this.setSrc('antenna'); + } + })); + const listItems = lists.map(list => ({ + text: list.name, + icon: 'fas fa-list-ul', + action: () => { + this.props.list = list; + this.setSrc('list'); + } + })); + os.popupMenu([{ + text: this.$ts._timelines.home, + icon: 'fas fa-home', + action: () => { this.setSrc('home') } + }, { + text: this.$ts._timelines.local, + icon: 'fas fa-comments', + action: () => { this.setSrc('local') } + }, { + text: this.$ts._timelines.social, + icon: 'fas fa-share-alt', + action: () => { this.setSrc('social') } + }, { + text: this.$ts._timelines.global, + icon: 'fas fa-globe', + action: () => { this.setSrc('global') } + }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget || ev.target).then(() => { + this.menuOpened = false; + }); + }, + + setSrc(src) { + this.props.src = src; + this.save(); + }, + } +}); +</script> diff --git a/packages/client/src/widgets/trends.vue b/packages/client/src/widgets/trends.vue new file mode 100644 index 0000000000..dba3392618 --- /dev/null +++ b/packages/client/src/widgets/trends.vue @@ -0,0 +1,111 @@ +<template> +<MkContainer :show-header="props.showHeader"> + <template #header><i class="fas fa-hashtag"></i>{{ $ts._widgets.trends }}</template> + + <div class="wbrkwala"> + <MkLoading v-if="fetching"/> + <transition-group tag="div" name="chart" class="tags" v-else> + <div v-for="stat in stats" :key="stat.tag"> + <div class="tag"> + <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA> + <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p> + </div> + <MkMiniChart class="chart" :src="stat.chart"/> + </div> + </transition-group> + </div> +</MkContainer> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkContainer from '@/components/ui/container.vue'; +import define from './define'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; + +const widget = define({ + name: 'hashtags', + props: () => ({ + showHeader: { + type: 'boolean', + default: true, + }, + }) +}); + +export default defineComponent({ + extends: widget, + components: { + MkContainer, MkMiniChart + }, + data() { + return { + stats: [], + fetching: true, + }; + }, + mounted() { + this.fetch(); + this.clock = setInterval(this.fetch, 1000 * 60); + }, + beforeUnmount() { + clearInterval(this.clock); + }, + methods: { + fetch() { + os.api('hashtags/trend').then(stats => { + this.stats = stats; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.wbrkwala { + height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px; + overflow: hidden; + + > .tags { + .chart-move { + transition: transform 1s ease; + } + + > div { + display: flex; + align-items: center; + padding: 14px 16px; + border-bottom: solid 0.5px var(--divider); + + > .tag { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + + > .a { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 18px; + } + + > p { + margin: 0; + font-size: 75%; + opacity: 0.7; + line-height: 16px; + } + } + + > .chart { + height: 30px; + } + } + } +} +</style> |