diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-12 02:02:25 +0900 |
| commit | 0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch) | |
| tree | 40874799472fa07416f17b50a398ac33b7771905 /src/client | |
| parent | update deps (diff) | |
| download | sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2 sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip | |
refactoring
Resolve #7779
Diffstat (limited to 'src/client')
519 files changed, 0 insertions, 72011 deletions
diff --git a/src/client/.eslintrc b/src/client/.eslintrc deleted file mode 100644 index fffa28d9e4..0000000000 --- a/src/client/.eslintrc +++ /dev/null @@ -1,32 +0,0 @@ -{ - "env": { - "node": false, - }, - "extends": [ - "eslint:recommended", - "plugin:vue/recommended" - ], - "rules": { - "vue/require-v-for-key": 0, - "vue/max-attributes-per-line": 0, - "vue/html-indent": 0, - "vue/html-self-closing": 0, - "vue/no-unused-vars": 0, - "vue/attributes-order": 0, - "vue/require-prop-types": 0, - "vue/require-default-prop": 0, - "vue/html-closing-bracket-spacing": 0, - "vue/singleline-html-element-content-newline": 0, - "vue/no-v-html": 0 - }, - "globals": { - "_DEV_": false, - "_LANGS_": false, - "_VERSION_": false, - "_ENV_": false, - "_PERF_PREFIX_": false, - "_DATA_TRANSFER_DRIVE_FILE_": false, - "_DATA_TRANSFER_DRIVE_FOLDER_": false, - "_DATA_TRANSFER_DECK_COLUMN_": false - } -} diff --git a/src/client/@types/global.d.ts b/src/client/@types/global.d.ts deleted file mode 100644 index 84dde63b22..0000000000 --- a/src/client/@types/global.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -declare const _LANGS_: string[][]; -declare const _VERSION_: string; -declare const _ENV_: string; -declare const _DEV_: boolean; -declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; diff --git a/src/client/@types/vue.d.ts b/src/client/@types/vue.d.ts deleted file mode 100644 index 8cb6130629..0000000000 --- a/src/client/@types/vue.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module '*.vue' { - import type { DefineComponent } from 'vue'; - const component: DefineComponent<{}, {}, any>; - export default component; -} diff --git a/src/client/account.ts b/src/client/account.ts deleted file mode 100644 index a2165ebed1..0000000000 --- a/src/client/account.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { del, get, set } from '@client/scripts/idb-proxy'; -import { reactive } from 'vue'; -import { apiUrl } from '@client/config'; -import { waiting, api, popup, popupMenu, success } from '@client/os'; -import { unisonReload, reloadChannel } from '@client/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('@client/components/signin-dialog.vue'), {}, { - done: res => { - addAccount(res.id, res.i); - success(); - }, - }, 'closed'); - } - - function createAccount() { - popup(import('@client/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/src/client/components/abuse-report-window.vue b/src/client/components/abuse-report-window.vue deleted file mode 100644 index 21a19385ae..0000000000 --- a/src/client/components/abuse-report-window.vue +++ /dev/null @@ -1,79 +0,0 @@ -<template> -<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')"> - <template #header> - <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i> - <I18n :src="$ts.reportAbuseOf" tag="span"> - <template #name> - <b><MkAcct :user="user"/></b> - </template> - </I18n> - </template> - <div class="dpvffvvy _monolithic_"> - <div class="_section"> - <MkTextarea v-model="comment"> - <template #label>{{ $ts.details }}</template> - <template #caption>{{ $ts.fillAbuseReportDescription }}</template> - </MkTextarea> - </div> - <div class="_section"> - <MkButton @click="send" primary full :disabled="comment.length === 0">{{ $ts.send }}</MkButton> - </div> - </div> -</XWindow> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import XWindow from '@client/components/ui/window.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XWindow, - MkTextarea, - MkButton, - }, - - props: { - user: { - type: Object, - required: true, - }, - initialComment: { - type: String, - required: false, - }, - }, - - emits: ['closed'], - - data() { - return { - comment: this.initialComment || '', - }; - }, - - methods: { - send() { - os.apiWithDialog('users/report-abuse', { - userId: this.user.id, - comment: this.comment, - }, undefined, res => { - os.dialog({ - type: 'success', - text: this.$ts.abuseReported - }); - this.$refs.window.close(); - }); - } - }, -}); -</script> - -<style lang="scss" scoped> -.dpvffvvy { - --root-margin: 16px; -} -</style> diff --git a/src/client/components/analog-clock.vue b/src/client/components/analog-clock.vue deleted file mode 100644 index bc572e5fff..0000000000 --- a/src/client/components/analog-clock.vue +++ /dev/null @@ -1,150 +0,0 @@ -<template> -<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none"> - <circle v-for="(angle, i) in graduations" - :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))" - :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))" - :r="i % 5 == 0 ? 0.125 : 0.05" - :fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor" - :key="i" - /> - - <line - :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))" - :stroke="sHandColor" - :stroke-width="thickness / 2" - stroke-linecap="round" - /> - - <line - :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))" - :stroke="mHandColor" - :stroke-width="thickness" - stroke-linecap="round" - /> - - <line - :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))" - :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))" - :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" - :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))" - :stroke="hHandColor" - :stroke-width="thickness" - stroke-linecap="round" - /> -</svg> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as tinycolor from 'tinycolor2'; - -export default defineComponent({ - props: { - thickness: { - type: Number, - default: 0.1 - } - }, - - data() { - return { - now: new Date(), - enabled: true, - - graduationsPadding: 0.5, - handsPadding: 1, - handsTailLength: 0.7, - hHandLengthRatio: 0.75, - mHandLengthRatio: 1, - sHandLengthRatio: 1, - - computedStyle: getComputedStyle(document.documentElement) - }; - }, - - computed: { - dark(): boolean { - return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark(); - }, - - majorGraduationColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; - }, - minorGraduationColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - }, - - sHandColor(): string { - return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; - }, - mHandColor(): string { - return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString(); - }, - hHandColor(): string { - return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString(); - }, - - s(): number { - return this.now.getSeconds(); - }, - m(): number { - return this.now.getMinutes(); - }, - h(): number { - return this.now.getHours(); - }, - - hAngle(): number { - return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6; - }, - mAngle(): number { - return Math.PI * (this.m + this.s / 60) / 30; - }, - sAngle(): number { - return Math.PI * this.s / 30; - }, - - graduations(): any { - const angles = []; - for (let i = 0; i < 60; i++) { - const angle = Math.PI * i / 30; - angles.push(angle); - } - - return angles; - } - }, - - mounted() { - const update = () => { - if (this.enabled) { - this.tick(); - setTimeout(update, 1000); - } - }; - update(); - }, - - beforeUnmount() { - this.enabled = false; - }, - - methods: { - tick() { - this.now = new Date(); - } - } -}); -</script> - -<style lang="scss" scoped> -.mbcofsoe { - display: block; -} -</style> diff --git a/src/client/components/autocomplete.vue b/src/client/components/autocomplete.vue deleted file mode 100644 index e621b26229..0000000000 --- a/src/client/components/autocomplete.vue +++ /dev/null @@ -1,502 +0,0 @@ -<template> -<div class="swhvrteh _popup _shadow" @contextmenu.prevent="() => {}"> - <ol class="users" ref="suggests" v-if="type === 'user'"> - <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user"> - <img class="avatar" :src="user.avatarUrl"/> - <span class="name"> - <MkUserName :user="user" :key="user.id"/> - </span> - <span class="username">@{{ acct(user) }}</span> - </li> - <li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $ts.selectUser }}</li> - </ol> - <ol class="hashtags" ref="suggests" v-else-if="hashtags.length > 0"> - <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1"> - <span class="name">{{ hashtag }}</span> - </li> - </ol> - <ol class="emojis" ref="suggests" v-else-if="emojis.length > 0"> - <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1"> - <span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> - <span class="emoji" v-else-if="!$store.state.useOsNativeEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span> - <span class="emoji" v-else>{{ emoji.emoji }}</span> - <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> - <span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span> - </li> - </ol> - <ol class="mfmTags" ref="suggests" v-else-if="mfmTags.length > 0"> - <li v-for="tag in mfmTags" @click="complete(type, tag)" @keydown="onKeydown" tabindex="-1"> - <span class="tag">{{ tag }}</span> - </li> - </ol> -</div> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import { emojilist } from '@/misc/emojilist'; -import contains from '@client/scripts/contains'; -import { twemojiSvgBase } from '@/misc/twemoji-base'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import { acct } from '@client/filters/user'; -import * as os from '@client/os'; -import { instance } from '@client/instance'; - -type EmojiDef = { - emoji: string; - name: string; - aliasOf?: string; - url?: string; - isCustomEmoji?: boolean; -}; - -const lib = emojilist.filter(x => x.category !== 'flags'); - -const char2file = (char: string) => { - let codes = Array.from(char).map(x => x.codePointAt(0).toString(16)); - if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); - codes = codes.filter(x => x && x.length); - return codes.join('-'); -}; - -const emjdb: EmojiDef[] = lib.map(x => ({ - emoji: x.char, - name: x.name, - aliasOf: null, - url: `${twemojiSvgBase}/${char2file(x.char)}.svg` -})); - -for (const x of lib) { - if (x.keywords) { - for (const k of x.keywords) { - emjdb.push({ - emoji: x.char, - name: k, - aliasOf: x.name, - url: `${twemojiSvgBase}/${char2file(x.char)}.svg` - }); - } - } -} - -emjdb.sort((a, b) => a.name.length - b.name.length); - -//#region Construct Emoji DB -const customEmojis = instance.emojis; -const emojiDefinitions: EmojiDef[] = []; - -for (const x of customEmojis) { - emojiDefinitions.push({ - name: x.name, - emoji: `:${x.name}:`, - url: x.url, - isCustomEmoji: true - }); - - if (x.aliases) { - for (const alias of x.aliases) { - emojiDefinitions.push({ - name: alias, - aliasOf: x.name, - emoji: `:${x.name}:`, - url: x.url, - isCustomEmoji: true - }); - } - } -} - -emojiDefinitions.sort((a, b) => a.name.length - b.name.length); - -const emojiDb = markRaw(emojiDefinitions.concat(emjdb)); -//#endregion - -const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle']; - -export default defineComponent({ - props: { - type: { - type: String, - required: true, - }, - - q: { - type: String, - required: false, - }, - - textarea: { - type: HTMLTextAreaElement, - required: true, - }, - - close: { - type: Function, - required: true, - }, - - x: { - type: Number, - required: true, - }, - - y: { - type: Number, - required: true, - }, - }, - - emits: ['done', 'closed'], - - data() { - return { - getStaticImageUrl, - fetching: true, - users: [], - hashtags: [], - emojis: [], - items: [], - mfmTags: [], - select: -1, - } - }, - - updated() { - this.setPosition(); - this.items = (this.$refs.suggests as Element | undefined)?.children || []; - }, - - mounted() { - this.setPosition(); - - this.textarea.addEventListener('keydown', this.onKeydown); - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - - this.$nextTick(() => { - this.exec(); - - this.$watch('q', () => { - this.$nextTick(() => { - this.exec(); - }); - }); - }); - }, - - beforeUnmount() { - this.textarea.removeEventListener('keydown', this.onKeydown); - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - }, - - methods: { - complete(type, value) { - this.$emit('done', { type, value }); - this.$emit('closed'); - - if (type === 'emoji') { - let recents = this.$store.state.recentlyUsedEmojis; - recents = recents.filter((e: any) => e !== value); - recents.unshift(value); - this.$store.set('recentlyUsedEmojis', recents.splice(0, 32)); - } - }, - - setPosition() { - if (this.x + this.$el.offsetWidth > window.innerWidth) { - this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px'; - } else { - this.$el.style.left = this.x + 'px'; - } - - if (this.y + this.$el.offsetHeight > window.innerHeight) { - this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px'; - this.$el.style.marginTop = '0'; - } else { - this.$el.style.top = this.y + 'px'; - this.$el.style.marginTop = 'calc(1em + 8px)'; - } - }, - - exec() { - this.select = -1; - if (this.$refs.suggests) { - for (const el of Array.from(this.items)) { - el.removeAttribute('data-selected'); - } - } - - if (this.type === 'user') { - if (this.q == null) { - this.users = []; - this.fetching = false; - return; - } - - const cacheKey = `autocomplete:user:${this.q}`; - const cache = sessionStorage.getItem(cacheKey); - if (cache) { - const users = JSON.parse(cache); - this.users = users; - this.fetching = false; - } else { - os.api('users/search-by-username-and-host', { - username: this.q, - limit: 10, - detail: false - }).then(users => { - this.users = users; - this.fetching = false; - - // キャッシュ - sessionStorage.setItem(cacheKey, JSON.stringify(users)); - }); - } - } else if (this.type === 'hashtag') { - if (this.q == null || this.q == '') { - this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]'); - this.fetching = false; - } else { - const cacheKey = `autocomplete:hashtag:${this.q}`; - const cache = sessionStorage.getItem(cacheKey); - if (cache) { - const hashtags = JSON.parse(cache); - this.hashtags = hashtags; - this.fetching = false; - } else { - os.api('hashtags/search', { - query: this.q, - limit: 30 - }).then(hashtags => { - this.hashtags = hashtags; - this.fetching = false; - - // キャッシュ - sessionStorage.setItem(cacheKey, JSON.stringify(hashtags)); - }); - } - } - } else if (this.type === 'emoji') { - if (this.q == null || this.q == '') { - // 最近使った絵文字をサジェスト - this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null); - return; - } - - const matched = []; - const max = 30; - - emojiDb.some(x => { - if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x); - return matched.length == max; - }); - if (matched.length < max) { - emojiDb.some(x => { - if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); - return matched.length == max; - }); - } - if (matched.length < max) { - emojiDb.some(x => { - if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x); - return matched.length == max; - }); - } - - this.emojis = matched; - } else if (this.type === 'mfmTag') { - if (this.q == null || this.q == '') { - this.mfmTags = MFM_TAGS; - return; - } - - this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q)); - } - }, - - onMousedown(e) { - if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); - }, - - onKeydown(e) { - const cancel = () => { - e.preventDefault(); - e.stopPropagation(); - }; - - switch (e.which) { - case 10: // [ENTER] - case 13: // [ENTER] - if (this.select !== -1) { - cancel(); - (this.items[this.select] as any).click(); - } else { - this.close(); - } - break; - - case 27: // [ESC] - cancel(); - this.close(); - break; - - case 38: // [↑] - if (this.select !== -1) { - cancel(); - this.selectPrev(); - } else { - this.close(); - } - break; - - case 9: // [TAB] - case 40: // [↓] - cancel(); - this.selectNext(); - break; - - default: - e.stopPropagation(); - this.textarea.focus(); - } - }, - - selectNext() { - if (++this.select >= this.items.length) this.select = 0; - if (this.items.length === 0) this.select = -1; - this.applySelect(); - }, - - selectPrev() { - if (--this.select < 0) this.select = this.items.length - 1; - this.applySelect(); - }, - - applySelect() { - for (const el of Array.from(this.items)) { - el.removeAttribute('data-selected'); - } - - if (this.select !== -1) { - this.items[this.select].setAttribute('data-selected', 'true'); - (this.items[this.select] as any).focus(); - } - }, - - chooseUser() { - this.close(); - os.selectUser().then(user => { - this.complete('user', user); - this.textarea.focus(); - }); - }, - - acct - } -}); -</script> - -<style lang="scss" scoped> -.swhvrteh { - position: fixed; - z-index: 65535; - max-width: 100%; - margin-top: calc(1em + 8px); - overflow: hidden; - transition: top 0.1s ease, left 0.1s ease; - - > ol { - display: block; - margin: 0; - padding: 4px 0; - max-height: 190px; - max-width: 500px; - overflow: auto; - list-style: none; - - > li { - display: flex; - align-items: center; - padding: 4px 12px; - white-space: nowrap; - overflow: hidden; - font-size: 0.9em; - cursor: default; - - &, * { - user-select: none; - } - - * { - overflow: hidden; - text-overflow: ellipsis; - } - - &:hover { - background: var(--X3); - } - - &[data-selected='true'] { - background: var(--accent); - - &, * { - color: #fff !important; - } - } - - &:active { - background: var(--accentDarken); - - &, * { - color: #fff !important; - } - } - } - } - - > .users > li { - - .avatar { - min-width: 28px; - min-height: 28px; - max-width: 28px; - max-height: 28px; - margin: 0 8px 0 0; - border-radius: 100%; - } - - .name { - margin: 0 8px 0 0; - } - } - - > .emojis > li { - - .emoji { - display: inline-block; - margin: 0 4px 0 0; - width: 24px; - - > img { - width: 24px; - vertical-align: bottom; - } - } - - .alias { - margin: 0 0 0 8px; - } - } - - > .mfmTags > li { - - .name { - } - } -} -</style> diff --git a/src/client/components/avatars.vue b/src/client/components/avatars.vue deleted file mode 100644 index da862967dd..0000000000 --- a/src/client/components/avatars.vue +++ /dev/null @@ -1,30 +0,0 @@ -<template> -<div> - <div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;"> - <MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - userIds: { - required: true - }, - }, - data() { - return { - us: [] - }; - }, - async created() { - this.us = await os.api('users/show', { - userIds: this.userIds - }); - } -}); -</script> diff --git a/src/client/components/captcha.vue b/src/client/components/captcha.vue deleted file mode 100644 index baa922506e..0000000000 --- a/src/client/components/captcha.vue +++ /dev/null @@ -1,123 +0,0 @@ -<template> -<div> - <span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span> - <div ref="captcha"></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; - -type Captcha = { - render(container: string | Node, options: { - readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; - }): string; - remove(id: string): void; - execute(id: string): void; - reset(id: string): void; - getResponse(id: string): string; -}; - -type CaptchaProvider = 'hcaptcha' | 'recaptcha'; - -type CaptchaContainer = { - readonly [_ in CaptchaProvider]?: Captcha; -}; - -declare global { - interface Window extends CaptchaContainer { - } -} - -export default defineComponent({ - props: { - provider: { - type: String as PropType<CaptchaProvider>, - required: true, - }, - sitekey: { - type: String, - required: true, - }, - modelValue: { - type: String, - }, - }, - - data() { - return { - available: false, - }; - }, - - computed: { - variable(): string { - switch (this.provider) { - case 'hcaptcha': return 'hcaptcha'; - case 'recaptcha': return 'grecaptcha'; - } - }, - loaded(): boolean { - return !!window[this.variable]; - }, - src(): string { - const endpoint = ({ - hcaptcha: 'https://hcaptcha.com/1', - recaptcha: 'https://www.recaptcha.net/recaptcha', - } as Record<CaptchaProvider, string>)[this.provider]; - - return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`; - }, - captcha(): Captcha { - return window[this.variable] || {} as unknown as Captcha; - }, - }, - - created() { - if (this.loaded) { - this.available = true; - } else { - (document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { - async: true, - id: this.provider, - src: this.src, - }))) - .addEventListener('load', () => this.available = true); - } - }, - - mounted() { - if (this.available) { - this.requestRender(); - } else { - this.$watch('available', this.requestRender); - } - }, - - beforeUnmount() { - this.reset(); - }, - - methods: { - reset() { - if (this.captcha?.reset) this.captcha.reset(); - }, - requestRender() { - if (this.captcha.render && this.$refs.captcha instanceof Element) { - this.captcha.render(this.$refs.captcha, { - sitekey: this.sitekey, - theme: this.$store.state.darkMode ? 'dark' : 'light', - callback: this.callback, - 'expired-callback': this.callback, - 'error-callback': this.callback, - }); - } else { - setTimeout(this.requestRender.bind(this), 1); - } - }, - callback(response?: string) { - this.$emit('update:modelValue', typeof response == 'string' ? response : null); - }, - }, -}); -</script> diff --git a/src/client/components/channel-follow-button.vue b/src/client/components/channel-follow-button.vue deleted file mode 100644 index bd8627f6e8..0000000000 --- a/src/client/components/channel-follow-button.vue +++ /dev/null @@ -1,140 +0,0 @@ -<template> -<button class="hdcaacmi _button" - :class="{ wait, active: isFollowing, full }" - @click="onClick" - :disabled="wait" -> - <template v-if="!wait"> - <template v-if="isFollowing"> - <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i> - </template> - <template v-else> - <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i> - </template> - </template> - <template v-else> - <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> - </template> -</button> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - channel: { - type: Object, - required: true - }, - full: { - type: Boolean, - required: false, - default: false, - }, - }, - - data() { - return { - isFollowing: this.channel.isFollowing, - wait: false, - }; - }, - - methods: { - async onClick() { - this.wait = true; - - try { - if (this.isFollowing) { - await os.api('channels/unfollow', { - channelId: this.channel.id - }); - this.isFollowing = false; - } else { - await os.api('channels/follow', { - channelId: this.channel.id - }); - this.isFollowing = true; - } - } catch (e) { - console.error(e); - } finally { - this.wait = false; - } - } - } -}); -</script> - -<style lang="scss" scoped> -.hdcaacmi { - position: relative; - display: inline-block; - font-weight: bold; - color: var(--accent); - background: transparent; - border: solid 1px var(--accent); - padding: 0; - height: 31px; - font-size: 16px; - border-radius: 32px; - background: #fff; - - &.full { - padding: 0 8px 0 12px; - font-size: 14px; - } - - &:not(.full) { - width: 31px; - } - - &:focus-visible { - &:after { - content: ""; - pointer-events: none; - position: absolute; - top: -5px; - right: -5px; - bottom: -5px; - left: -5px; - border: 2px solid var(--focus); - border-radius: 32px; - } - } - - &:hover { - //background: mix($primary, #fff, 20); - } - - &:active { - //background: mix($primary, #fff, 40); - } - - &.active { - color: #fff; - background: var(--accent); - - &:hover { - background: var(--accentLighten); - border-color: var(--accentLighten); - } - - &:active { - background: var(--accentDarken); - border-color: var(--accentDarken); - } - } - - &.wait { - cursor: wait !important; - opacity: 0.7; - } - - > span { - margin-right: 6px; - } -} -</style> diff --git a/src/client/components/channel-preview.vue b/src/client/components/channel-preview.vue deleted file mode 100644 index eb00052a78..0000000000 --- a/src/client/components/channel-preview.vue +++ /dev/null @@ -1,165 +0,0 @@ -<template> -<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> - <div class="banner" :style="bannerStyle"> - <div class="fade"></div> - <div class="name"><i class="fas fa-satellite-dish"></i> {{ channel.name }}</div> - <div class="status"> - <div> - <i class="fas fa-users fa-fw"></i> - <I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"> - <template #n> - <b>{{ channel.usersCount }}</b> - </template> - </I18n> - </div> - <div> - <i class="fas fa-pencil-alt fa-fw"></i> - <I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"> - <template #n> - <b>{{ channel.notesCount }}</b> - </template> - </I18n> - </div> - </div> - </div> - <article v-if="channel.description"> - <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> - </article> - <footer> - <span v-if="channel.lastNotedAt"> - {{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> - </span> - </footer> -</MkA> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - channel: { - type: Object, - required: true - }, - }, - - computed: { - bannerStyle() { - if (this.channel.bannerUrl) { - return { backgroundImage: `url(${this.channel.bannerUrl})` }; - } else { - return { backgroundColor: '#4c5e6d' }; - } - } - }, - - data() { - return { - }; - }, -}); -</script> - -<style lang="scss" scoped> -.eftoefju { - display: block; - overflow: hidden; - width: 100%; - - &:hover { - text-decoration: none; - } - - > .banner { - position: relative; - width: 100%; - height: 200px; - background-position: center; - background-size: cover; - - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 64px; - background: linear-gradient(0deg, var(--panel), var(--X15)); - } - - > .name { - position: absolute; - top: 16px; - left: 16px; - padding: 12px 16px; - background: rgba(0, 0, 0, 0.7); - color: #fff; - font-size: 1.2em; - } - - > .status { - position: absolute; - z-index: 1; - bottom: 16px; - right: 16px; - padding: 8px 12px; - font-size: 80%; - background: rgba(0, 0, 0, 0.7); - border-radius: 6px; - color: #fff; - } - } - - > article { - padding: 16px; - - > p { - margin: 0; - font-size: 1em; - } - } - - > footer { - padding: 12px 16px; - border-top: solid 0.5px var(--divider); - - > span { - opacity: 0.7; - font-size: 0.9em; - } - } - - @media (max-width: 550px) { - font-size: 0.9em; - - > .banner { - height: 80px; - - > .status { - display: none; - } - } - - > article { - padding: 12px; - } - - > footer { - display: none; - } - } - - @media (max-width: 500px) { - font-size: 0.8em; - - > .banner { - height: 70px; - } - - > article { - padding: 8px; - } - } -} - -</style> diff --git a/src/client/components/chart.vue b/src/client/components/chart.vue deleted file mode 100644 index ae9a5e79b1..0000000000 --- a/src/client/components/chart.vue +++ /dev/null @@ -1,691 +0,0 @@ -<template> -<div class="cbbedffa"> - <canvas ref="chartEl"></canvas> - <div v-if="fetching" class="fetching"> - <MkLoading/> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, ref, watch, PropType } from 'vue'; -import { - Chart, - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -} from 'chart.js'; -import 'chartjs-adapter-date-fns'; -import { enUS } from 'date-fns/locale'; -import zoomPlugin from 'chartjs-plugin-zoom'; -import * as os from '@client/os'; -import { defaultStore } from '@client/store'; - -Chart.register( - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, - zoomPlugin, -); - -const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); -const negate = arr => arr.map(x => -x); -const alpha = (hex, a) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - return `rgba(${r}, ${g}, ${b}, ${a})`; -}; - -const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560']; -const getColor = (i) => { - return colors[i % colors.length]; -}; - -export default defineComponent({ - props: { - src: { - type: String, - required: true, - }, - args: { - type: Object, - required: false, - }, - limit: { - type: Number, - required: false, - default: 90 - }, - span: { - type: String as PropType<'hour' | 'day'>, - required: true, - }, - detailed: { - type: Boolean, - required: false, - default: false - }, - stacked: { - type: Boolean, - required: false, - default: false - }, - aspectRatio: { - type: Number, - required: false, - default: null - }, - }, - - setup(props) { - const now = new Date(); - let chartInstance: Chart = null; - let data: { - series: { - name: string; - type: 'line' | 'area'; - color?: string; - borderDash?: number[]; - hidden?: boolean; - data: { - x: number; - y: number; - }[]; - }[]; - } = null; - - const chartEl = ref<HTMLCanvasElement>(null); - const fetching = ref(true); - - const getDate = (ago: number) => { - const y = now.getFullYear(); - const m = now.getMonth(); - const d = now.getDate(); - const h = now.getHours(); - - return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago); - }; - - const format = (arr) => { - return arr.map((v, i) => ({ - x: getDate(i).getTime(), - y: v - })); - }; - - const render = () => { - if (chartInstance) { - chartInstance.destroy(); - } - - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - chartInstance = new Chart(chartEl.value, { - type: 'line', - data: { - labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), - datasets: data.series.map((x, i) => ({ - parsing: false, - label: x.name, - data: x.data.slice().reverse(), - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderColor: x.color ? x.color : getColor(i), - borderDash: x.borderDash || [], - borderJoinStyle: 'round', - backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1), - fill: x.type === 'area', - hidden: !!x.hidden, - })), - }, - options: { - aspectRatio: props.aspectRatio || 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 8, - }, - }, - scales: { - x: { - type: 'time', - time: { - stepSize: 1, - unit: props.span === 'day' ? 'month' : 'day', - }, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - ticks: { - display: props.detailed, - }, - adapters: { - date: { - locale: enUS, - }, - }, - min: getDate(props.limit).getTime(), - }, - y: { - position: 'left', - stacked: props.stacked, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - ticks: { - display: props.detailed, - }, - }, - }, - interaction: { - intersect: false, - }, - plugins: { - legend: { - display: props.detailed, - position: 'bottom', - labels: { - boxWidth: 16, - }, - }, - tooltip: { - mode: 'index', - animation: { - duration: 0, - }, - }, - zoom: { - pan: { - enabled: true, - }, - zoom: { - wheel: { - enabled: true, - }, - pinch: { - enabled: true, - }, - drag: { - enabled: false, - }, - mode: 'x', - }, - limits: { - x: { - min: 'original', - max: 'original', - }, - y: { - min: 'original', - max: 'original', - }, - } - }, - }, - }, - }); - }; - - const exportData = () => { - // TODO - }; - - const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => { - const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Instances', - type: 'area', - data: format(total - ? raw.instance.total - : sum(raw.instance.inc, negate(raw.instance.dec)) - ), - }], - }; - }; - - const fetchNotesChart = async (type: string): Promise<typeof data> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); - return { - series: [{ - name: 'All', - type: 'line', - borderDash: [5, 5], - data: format(type == 'combined' - ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) - : sum(raw[type].inc, negate(raw[type].dec)) - ), - }, { - name: 'Renotes', - type: 'area', - data: format(type == 'combined' - ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) - : raw[type].diffs.renote - ), - }, { - name: 'Replies', - type: 'area', - data: format(type == 'combined' - ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) - : raw[type].diffs.reply - ), - }, { - name: 'Normal', - type: 'area', - data: format(type == 'combined' - ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) - : raw[type].diffs.normal - ), - }], - }; - }; - - const fetchNotesTotalChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Combined', - type: 'line', - data: format(sum(raw.local.total, raw.remote.total)), - }, { - name: 'Local', - type: 'area', - data: format(raw.local.total), - }, { - name: 'Remote', - type: 'area', - data: format(raw.remote.total), - }], - }; - }; - - const fetchUsersChart = async (total: boolean): Promise<typeof data> => { - const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Combined', - type: 'line', - data: format(total - ? sum(raw.local.total, raw.remote.total) - : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) - ), - }, { - name: 'Local', - type: 'area', - data: format(total - ? raw.local.total - : sum(raw.local.inc, negate(raw.local.dec)) - ), - }, { - name: 'Remote', - type: 'area', - data: format(total - ? raw.remote.total - : sum(raw.remote.inc, negate(raw.remote.dec)) - ), - }], - }; - }; - - const fetchActiveUsersChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Combined', - type: 'line', - data: format(sum(raw.local.users, raw.remote.users)), - }, { - name: 'Local', - type: 'area', - data: format(raw.local.users), - }, { - name: 'Remote', - type: 'area', - data: format(raw.remote.users), - }], - }; - }; - - const fetchDriveChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); - return { - bytes: true, - series: [{ - name: 'All', - type: 'line', - borderDash: [5, 5], - data: format( - sum( - raw.local.incSize, - negate(raw.local.decSize), - raw.remote.incSize, - negate(raw.remote.decSize) - ) - ), - }, { - name: 'Local +', - type: 'area', - data: format(raw.local.incSize), - }, { - name: 'Local -', - type: 'area', - data: format(negate(raw.local.decSize)), - }, { - name: 'Remote +', - type: 'area', - data: format(raw.remote.incSize), - }, { - name: 'Remote -', - type: 'area', - data: format(negate(raw.remote.decSize)), - }], - }; - }; - - const fetchDriveTotalChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); - return { - bytes: true, - series: [{ - name: 'Combined', - type: 'line', - data: format(sum(raw.local.totalSize, raw.remote.totalSize)), - }, { - name: 'Local', - type: 'area', - data: format(raw.local.totalSize), - }, { - name: 'Remote', - type: 'area', - data: format(raw.remote.totalSize), - }], - }; - }; - - const fetchDriveFilesChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); - return { - series: [{ - name: 'All', - type: 'line', - borderDash: [5, 5], - data: format( - sum( - raw.local.incCount, - negate(raw.local.decCount), - raw.remote.incCount, - negate(raw.remote.decCount) - ) - ), - }, { - name: 'Local +', - type: 'area', - data: format(raw.local.incCount), - }, { - name: 'Local -', - type: 'area', - data: format(negate(raw.local.decCount)), - }, { - name: 'Remote +', - type: 'area', - data: format(raw.remote.incCount), - }, { - name: 'Remote -', - type: 'area', - data: format(negate(raw.remote.decCount)), - }], - }; - }; - - const fetchDriveFilesTotalChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Combined', - type: 'line', - data: format(sum(raw.local.totalCount, raw.remote.totalCount)), - }, { - name: 'Local', - type: 'area', - data: format(raw.local.totalCount), - }, { - name: 'Remote', - type: 'area', - data: format(raw.remote.totalCount), - }], - }; - }; - - const fetchInstanceRequestsChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); - return { - series: [{ - name: 'In', - type: 'area', - color: '#008FFB', - data: format(raw.requests.received) - }, { - name: 'Out (succ)', - type: 'area', - color: '#00E396', - data: format(raw.requests.succeeded) - }, { - name: 'Out (fail)', - type: 'area', - color: '#FEB019', - data: format(raw.requests.failed) - }] - }; - }; - - const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Users', - type: 'area', - color: '#008FFB', - data: format(total - ? raw.users.total - : sum(raw.users.inc, negate(raw.users.dec)) - ) - }] - }; - }; - - const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Notes', - type: 'area', - color: '#008FFB', - data: format(total - ? raw.notes.total - : sum(raw.notes.inc, negate(raw.notes.dec)) - ) - }] - }; - }; - - const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Following', - type: 'area', - color: '#008FFB', - data: format(total - ? raw.following.total - : sum(raw.following.inc, negate(raw.following.dec)) - ) - }, { - name: 'Followers', - type: 'area', - color: '#00E396', - data: format(total - ? raw.followers.total - : sum(raw.followers.inc, negate(raw.followers.dec)) - ) - }] - }; - }; - - const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); - return { - bytes: true, - series: [{ - name: 'Drive usage', - type: 'area', - color: '#008FFB', - data: format(total - ? raw.drive.totalUsage - : sum(raw.drive.incUsage, negate(raw.drive.decUsage)) - ) - }] - }; - }; - - const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); - return { - series: [{ - name: 'Drive files', - type: 'area', - color: '#008FFB', - data: format(total - ? raw.drive.totalFiles - : sum(raw.drive.incFiles, negate(raw.drive.decFiles)) - ) - }] - }; - }; - - const fetchPerUserNotesChart = async (): Promise<typeof data> => { - const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); - return { - series: [...(props.args.withoutAll ? [] : [{ - name: 'All', - type: 'line', - borderDash: [5, 5], - data: format(sum(raw.inc, negate(raw.dec))), - }]), { - name: 'Renotes', - type: 'area', - data: format(raw.diffs.renote), - }, { - name: 'Replies', - type: 'area', - data: format(raw.diffs.reply), - }, { - name: 'Normal', - type: 'area', - data: format(raw.diffs.normal), - }], - }; - }; - - const fetchAndRender = async () => { - const fetchData = () => { - switch (props.src) { - case 'federation-instances': return fetchFederationInstancesChart(false); - case 'federation-instances-total': return fetchFederationInstancesChart(true); - case 'users': return fetchUsersChart(false); - case 'users-total': return fetchUsersChart(true); - case 'active-users': return fetchActiveUsersChart(); - case 'notes': return fetchNotesChart('combined'); - case 'local-notes': return fetchNotesChart('local'); - case 'remote-notes': return fetchNotesChart('remote'); - case 'notes-total': return fetchNotesTotalChart(); - case 'drive': return fetchDriveChart(); - case 'drive-total': return fetchDriveTotalChart(); - case 'drive-files': return fetchDriveFilesChart(); - case 'drive-files-total': return fetchDriveFilesTotalChart(); - - case 'instance-requests': return fetchInstanceRequestsChart(); - case 'instance-users': return fetchInstanceUsersChart(false); - case 'instance-users-total': return fetchInstanceUsersChart(true); - case 'instance-notes': return fetchInstanceNotesChart(false); - case 'instance-notes-total': return fetchInstanceNotesChart(true); - case 'instance-ff': return fetchInstanceFfChart(false); - case 'instance-ff-total': return fetchInstanceFfChart(true); - case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false); - case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true); - case 'instance-drive-files': return fetchInstanceDriveFilesChart(false); - case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true); - - case 'per-user-notes': return fetchPerUserNotesChart(); - } - }; - fetching.value = true; - data = await fetchData(); - fetching.value = false; - render(); - }; - - watch(() => [props.src, props.span], fetchAndRender); - - onMounted(() => { - fetchAndRender(); - }); - - return { - chartEl, - fetching, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.cbbedffa { - position: relative; - - > .fetching { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - -webkit-backdrop-filter: var(--blur, blur(12px)); - backdrop-filter: var(--blur, blur(12px)); - display: flex; - justify-content: center; - align-items: center; - cursor: wait; - } -} -</style> diff --git a/src/client/components/code-core.vue b/src/client/components/code-core.vue deleted file mode 100644 index 9cff7b4448..0000000000 --- a/src/client/components/code-core.vue +++ /dev/null @@ -1,35 +0,0 @@ -<template> -<code v-if="inline" v-html="html" :class="`language-${prismLang}`"></code> -<pre v-else :class="`language-${prismLang}`"><code v-html="html" :class="`language-${prismLang}`"></code></pre> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import 'prismjs'; -import 'prismjs/themes/prism-okaidia.css'; - -export default defineComponent({ - props: { - code: { - type: String, - required: true - }, - lang: { - type: String, - required: false - }, - inline: { - type: Boolean, - required: false - } - }, - computed: { - prismLang() { - return Prism.languages[this.lang] ? this.lang : 'js'; - }, - html() { - return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang); - } - } -}); -</script> diff --git a/src/client/components/code.vue b/src/client/components/code.vue deleted file mode 100644 index f5d6c5673a..0000000000 --- a/src/client/components/code.vue +++ /dev/null @@ -1,27 +0,0 @@ -<template> -<XCode :code="code" :lang="lang" :inline="inline"/> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; - -export default defineComponent({ - components: { - XCode: defineAsyncComponent(() => import('./code-core.vue')) - }, - props: { - code: { - type: String, - required: true - }, - lang: { - type: String, - required: false - }, - inline: { - type: Boolean, - required: false - } - } -}); -</script> diff --git a/src/client/components/cw-button.vue b/src/client/components/cw-button.vue deleted file mode 100644 index 3a172f5d5e..0000000000 --- a/src/client/components/cw-button.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<button class="nrvgflfu _button" @click="toggle"> - <b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b> - <span v-if="!modelValue">{{ label }}</span> -</button> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { length } from 'stringz'; -import { concat } from '../../prelude/array'; - -export default defineComponent({ - props: { - modelValue: { - type: Boolean, - required: true - }, - note: { - type: Object, - required: true - } - }, - - computed: { - label(): string { - return concat([ - this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [], - this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [], - this.note.poll != null ? [this.$ts.poll] : [] - ] as string[][]).join(' / '); - } - }, - - methods: { - length, - - toggle() { - this.$emit('update:modelValue', !this.modelValue); - } - } -}); -</script> - -<style lang="scss" scoped> -.nrvgflfu { - display: inline-block; - padding: 4px 8px; - font-size: 0.7em; - color: var(--cwFg); - background: var(--cwBg); - border-radius: 2px; - - &:hover { - background: var(--cwHoverBg); - } - - > span { - margin-left: 4px; - - &:before { - content: '('; - } - - &:after { - content: ')'; - } - } -} -</style> diff --git a/src/client/components/date-separated-list.vue b/src/client/components/date-separated-list.vue deleted file mode 100644 index fa0b6d669c..0000000000 --- a/src/client/components/date-separated-list.vue +++ /dev/null @@ -1,188 +0,0 @@ -<script lang="ts"> -import { defineComponent, h, PropType, TransitionGroup } from 'vue'; -import MkAd from '@client/components/global/ad.vue'; - -export default defineComponent({ - props: { - items: { - type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, - required: true, - }, - direction: { - type: String, - required: false, - default: 'down' - }, - reversed: { - type: Boolean, - required: false, - default: false - }, - noGap: { - type: Boolean, - required: false, - default: false - }, - ad: { - type: Boolean, - required: false, - default: false - }, - }, - - methods: { - focus() { - this.$slots.default[0].elm.focus(); - }, - - getDateText(time: string) { - const date = new Date(time).getDate(); - const month = new Date(time).getMonth() + 1; - return this.$t('monthAndDay', { - month: month.toString(), - day: date.toString() - }); - } - }, - - render() { - if (this.items.length === 0) return; - - const renderChildren = () => this.items.map((item, i) => { - const el = this.$slots.default({ - item: item - })[0]; - if (el.key == null && item.id) el.key = item.id; - - if ( - i != this.items.length - 1 && - new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() - ) { - const separator = h('div', { - class: 'separator', - key: item.id + ':separator', - }, h('p', { - class: 'date' - }, [ - h('span', [ - h('i', { - class: 'fas fa-angle-up icon', - }), - this.getDateText(item.createdAt) - ]), - h('span', [ - this.getDateText(this.items[i + 1].createdAt), - h('i', { - class: 'fas fa-angle-down icon', - }) - ]) - ])); - - return [el, separator]; - } else { - if (this.ad && item._shouldInsertAd_) { - return [h(MkAd, { - class: 'a', // advertiseの意(ブロッカー対策) - key: item.id + ':ad', - prefer: ['horizontal', 'horizontal-big'], - }), el]; - } else { - return el; - } - } - }); - - return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? { - class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''), - name: 'list', - tag: 'div', - 'data-direction': this.direction, - 'data-reversed': this.reversed ? 'true' : 'false', - } : { - class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''), - }, { - default: renderChildren - }); - }, -}); -</script> - -<style lang="scss"> -.sqadhkmv { - > *:empty { - display: none; - } - - > *:not(:last-child) { - margin-bottom: var(--margin); - } - - > .list-move { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - - > .list-enter-active { - transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1); - } - - &[data-direction="up"] { - > .list-enter-from { - opacity: 0; - transform: translateY(64px); - } - } - - &[data-direction="down"] { - > .list-enter-from { - opacity: 0; - transform: translateY(-64px); - } - } - - > .separator { - text-align: center; - - > .date { - display: inline-block; - position: relative; - margin: 0; - padding: 0 16px; - line-height: 32px; - text-align: center; - font-size: 12px; - color: var(--dateLabelFg); - - > span { - &:first-child { - margin-right: 8px; - - > .icon { - margin-right: 8px; - } - } - - &:last-child { - margin-left: 8px; - - > .icon { - margin-left: 8px; - } - } - } - } - } - - &.noGap { - > * { - margin: 0 !important; - border: none; - border-radius: 0; - box-shadow: none; - - &:not(:last-child) { - border-bottom: solid 0.5px var(--divider); - } - } - } -} -</style> diff --git a/src/client/components/debobigego/base.vue b/src/client/components/debobigego/base.vue deleted file mode 100644 index f551a3478b..0000000000 --- a/src/client/components/debobigego/base.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> -<div class="rbusrurv" :class="{ wide: forceWide }" v-size="{ max: [400] }"> - <slot></slot> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - forceWide: { - type: Boolean, - required: false, - default: false, - } - } -}); -</script> - -<style lang="scss" scoped> -.rbusrurv { - // 他のCSSからも参照されるので消さないように - --debobigegoXPadding: 32px; - --debobigegoYPadding: 32px; - - --debobigegoContentHMargin: 16px; - - font-size: 95%; - line-height: 1.3em; - background: var(--bg); - padding: var(--debobigegoYPadding) var(--debobigegoXPadding); - max-width: 750px; - margin: 0 auto; - - &:not(.wide).max-width_400px { - --debobigegoXPadding: 0px; - - > ::v-deep(*) { - ._debobigegoPanel { - border: solid 0.5px var(--divider); - border-radius: 0; - border-left: none; - border-right: none; - } - - ._debobigego_group { - > *:not(._debobigegoNoConcat) { - &:not(:last-child):not(._debobigegoNoConcatPrev) { - &._debobigegoPanel, ._debobigegoPanel { - border-bottom: solid 0.5px var(--divider); - } - } - - &:not(:first-child):not(._debobigegoNoConcatNext) { - &._debobigegoPanel, ._debobigegoPanel { - border-top: none; - } - } - } - } - } - } -} -</style> diff --git a/src/client/components/debobigego/button.vue b/src/client/components/debobigego/button.vue deleted file mode 100644 index b883e817a4..0000000000 --- a/src/client/components/debobigego/button.vue +++ /dev/null @@ -1,81 +0,0 @@ -<template> -<div class="yzpgjkxe _debobigegoItem"> - <div class="_debobigegoLabel"><slot name="label"></slot></div> - <button class="main _button _debobigegoPanel _debobigegoClickable" :class="{ center, primary, danger }"> - <slot></slot> - <div class="suffix"> - <slot name="suffix"></slot> - <div class="icon"> - <slot name="suffixIcon"></slot> - </div> - </div> - </button> - <div class="_debobigegoCaption"><slot name="desc"></slot></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import './debobigego.scss'; - -export default defineComponent({ - props: { - primary: { - type: Boolean, - required: false, - default: false, - }, - danger: { - type: Boolean, - required: false, - default: false, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - center: { - type: Boolean, - required: false, - default: true, - } - }, -}); -</script> - -<style lang="scss" scoped> -.yzpgjkxe { - > .main { - display: flex; - width: 100%; - box-sizing: border-box; - padding: 14px 16px; - text-align: left; - align-items: center; - - &.center { - display: block; - text-align: center; - } - - &.primary { - color: var(--accent); - } - - &.danger { - color: #ff2a2a; - } - - > .suffix { - display: inline-flex; - margin-left: auto; - opacity: 0.7; - - > .icon { - margin-left: 1em; - } - } - } -} -</style> diff --git a/src/client/components/debobigego/debobigego.scss b/src/client/components/debobigego/debobigego.scss deleted file mode 100644 index 833b656b66..0000000000 --- a/src/client/components/debobigego/debobigego.scss +++ /dev/null @@ -1,52 +0,0 @@ -._debobigegoPanel { - background: var(--panel); - border-radius: var(--radius); - transition: background 0.2s ease; - - &._debobigegoClickable { - &:hover { - //background: var(--panelHighlight); - } - - &:active { - background: var(--panelHighlight); - transition: background 0s; - } - } -} - -._debobigegoLabel, -._debobigegoCaption { - font-size: 80%; - color: var(--fgTransparentWeak); - - &:empty { - display: none; - } -} - -._debobigegoLabel { - position: sticky; - top: var(--stickyTop, 0px); - z-index: 2; - margin: -8px calc(var(--debobigegoXPadding) * -1) 0 calc(var(--debobigegoXPadding) * -1); - padding: 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)) 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)); - background: var(--X17); - -webkit-backdrop-filter: var(--blur, blur(10px)); - backdrop-filter: var(--blur, blur(10px)); -} - -._themeChanging_ ._debobigegoLabel { - transition: none !important; - background: transparent; -} - -._debobigegoCaption { - padding: 8px var(--debobigegoContentHMargin) 0 var(--debobigegoContentHMargin); -} - -._debobigegoItem { - & + ._debobigegoItem { - margin-top: 24px; - } -} diff --git a/src/client/components/debobigego/group.vue b/src/client/components/debobigego/group.vue deleted file mode 100644 index cba2c6ec94..0000000000 --- a/src/client/components/debobigego/group.vue +++ /dev/null @@ -1,78 +0,0 @@ -<template> -<div class="vrtktovg _debobigegoItem _debobigegoNoConcat" v-size="{ max: [500] }" v-sticky-container> - <div class="_debobigegoLabel"><slot name="label"></slot></div> - <div class="main _debobigego_group" ref="child"> - <slot></slot> - </div> - <div class="_debobigegoCaption"><slot name="caption"></slot></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, ref } from 'vue'; - -export default defineComponent({ - setup(props, context) { - const child = ref<HTMLElement | null>(null); - - const scanChild = () => { - if (child.value == null) return; - const els = Array.from(child.value.children); - for (let i = 0; i < els.length; i++) { - const el = els[i]; - if (el.classList.contains('_debobigegoNoConcat')) { - if (els[i - 1]) els[i - 1].classList.add('_debobigegoNoConcatPrev'); - if (els[i + 1]) els[i + 1].classList.add('_debobigegoNoConcatNext'); - } - } - }; - - onMounted(() => { - scanChild(); - - const observer = new MutationObserver(records => { - scanChild(); - }); - - observer.observe(child.value, { - childList: true, - subtree: false, - attributes: false, - characterData: false, - }); - }); - - return { - child - }; - } -}); -</script> - -<style lang="scss" scoped> -.vrtktovg { - > .main { - > ::v-deep(*):not(._debobigegoNoConcat) { - &:not(._debobigegoNoConcatNext) { - margin: 0; - } - - &:not(:last-child):not(._debobigegoNoConcatPrev) { - &._debobigegoPanel, ._debobigegoPanel { - border-bottom: solid 0.5px var(--divider); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - } - - &:not(:first-child):not(._debobigegoNoConcatNext) { - &._debobigegoPanel, ._debobigegoPanel { - border-top: none; - border-top-left-radius: 0; - border-top-right-radius: 0; - } - } - } - } -} -</style> diff --git a/src/client/components/debobigego/info.vue b/src/client/components/debobigego/info.vue deleted file mode 100644 index 41afb03304..0000000000 --- a/src/client/components/debobigego/info.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<div class="fzenkabp _debobigegoItem"> - <div class="_debobigegoPanel" :class="{ warn }"> - <i v-if="warn" class="fas fa-exclamation-triangle"></i> - <i v-else class="fas fa-info-circle"></i> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - warn: { - type: Boolean, - required: false, - default: false - }, - }, - data() { - return { - }; - } -}); -</script> - -<style lang="scss" scoped> -.fzenkabp { - > div { - padding: 14px 16px; - font-size: 90%; - background: var(--infoBg); - color: var(--infoFg); - - &.warn { - background: var(--infoWarnBg); - color: var(--infoWarnFg); - } - - > i { - margin-right: 4px; - } - } -} -</style> diff --git a/src/client/components/debobigego/input.vue b/src/client/components/debobigego/input.vue deleted file mode 100644 index d113f04d27..0000000000 --- a/src/client/components/debobigego/input.vue +++ /dev/null @@ -1,292 +0,0 @@ -<template> -<FormGroup class="_debobigegoItem"> - <template #label><slot></slot></template> - <div class="ztzhwixg _debobigegoItem" :class="{ inline, disabled }"> - <div class="icon" ref="icon"><slot name="icon"></slot></div> - <div class="input _debobigegoPanel"> - <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <input ref="inputEl" - :type="type" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - :step="step" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - :list="id" - > - <datalist :id="id" v-if="datalist"> - <option v-for="data in datalist" :value="data"/> - </datalist> - <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> - </div> - </div> - <template #caption><slot name="desc"></slot></template> - - <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> -</FormGroup> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import './debobigego.scss'; -import FormButton from './button.vue'; -import FormGroup from './group.vue'; - -export default defineComponent({ - components: { - FormGroup, - FormButton, - }, - props: { - modelValue: { - required: false - }, - type: { - type: String, - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - autocomplete: { - required: false - }, - spellcheck: { - required: false - }, - step: { - required: false - }, - datalist: { - type: Array, - required: false, - }, - inline: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - emits: ['change', 'keydown', 'enter', 'update:modelValue'], - setup(props, context) { - const { modelValue, type, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const id = Math.random().toString(); // TODO: uuid? - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - const prefixEl = ref(null); - const suffixEl = ref(null); - - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - const onKeydown = (ev: KeyboardEvent) => { - context.emit('keydown', ev); - - if (ev.code === 'Enter') { - context.emit('enter'); - } - }; - - const updated = () => { - changed.value = false; - if (type?.value === 'number') { - context.emit('update:modelValue', parseFloat(v.value)); - } else { - context.emit('update:modelValue', v.value); - } - }; - - watch(modelValue.value, newValue => { - v.value = newValue; - }); - - watch(v, newValue => { - if (!props.manualSave) { - updated(); - } - - invalid.value = inputEl.value.validity.badInput; - }); - - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } - - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); - - onUnmounted(() => { - clearInterval(clock); - }); - }); - }); - - return { - id, - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - focus, - onInput, - onKeydown, - updated, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.ztzhwixg { - position: relative; - - > .icon { - position: absolute; - top: 0; - left: 0; - width: 24px; - text-align: center; - line-height: 32px; - - &:not(:empty) + .input { - margin-left: 28px; - } - } - - > .input { - $height: 48px; - position: relative; - - > input { - display: block; - height: $height; - width: 100%; - margin: 0; - padding: 0 16px; - font: inherit; - font-weight: normal; - font-size: 1em; - line-height: $height; - color: var(--inputText); - background: transparent; - border: none; - border-radius: 0; - outline: none; - box-shadow: none; - box-sizing: border-box; - - &[type='file'] { - display: none; - } - } - - > .prefix, - > .suffix { - display: block; - position: absolute; - z-index: 1; - top: 0; - padding: 0 16px; - font-size: 1em; - line-height: $height; - color: var(--inputLabel); - pointer-events: none; - - &:empty { - display: none; - } - - > * { - display: inline-block; - min-width: 16px; - max-width: 150px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - > .prefix { - left: 0; - padding-right: 8px; - } - - > .suffix { - right: 0; - padding-left: 8px; - } - } - - &.inline { - display: inline-block; - margin: 0; - } - - &.disabled { - opacity: 0.7; - - &, * { - cursor: not-allowed !important; - } - } -} -</style> diff --git a/src/client/components/debobigego/key-value-view.vue b/src/client/components/debobigego/key-value-view.vue deleted file mode 100644 index 0e034a2d54..0000000000 --- a/src/client/components/debobigego/key-value-view.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<div class="_debobigegoItem"> - <div class="_debobigegoPanel anocepby"> - <span class="key"><slot name="key"></slot></span> - <span class="value"><slot name="value"></slot></span> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import './debobigego.scss'; - -export default defineComponent({ - -}); -</script> - -<style lang="scss" scoped> -.anocepby { - display: flex; - align-items: center; - padding: 14px var(--debobigegoContentHMargin); - - > .key { - margin-right: 12px; - white-space: nowrap; - } - - > .value { - margin-left: auto; - opacity: 0.7; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } -} -</style> diff --git a/src/client/components/debobigego/link.vue b/src/client/components/debobigego/link.vue deleted file mode 100644 index 885579eadf..0000000000 --- a/src/client/components/debobigego/link.vue +++ /dev/null @@ -1,103 +0,0 @@ -<template> -<div class="qmfkfnzi _debobigegoItem"> - <a class="main _button _debobigegoPanel _debobigegoClickable" :href="to" target="_blank" v-if="external"> - <span class="icon"><slot name="icon"></slot></span> - <span class="text"><slot></slot></span> - <span class="right"> - <span class="text"><slot name="suffix"></slot></span> - <i class="fas fa-external-link-alt icon"></i> - </span> - </a> - <MkA class="main _button _debobigegoPanel _debobigegoClickable" :class="{ active }" :to="to" :behavior="behavior" v-else> - <span class="icon"><slot name="icon"></slot></span> - <span class="text"><slot></slot></span> - <span class="right"> - <span class="text"><slot name="suffix"></slot></span> - <i class="fas fa-chevron-right icon"></i> - </span> - </MkA> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import './debobigego.scss'; - -export default defineComponent({ - props: { - to: { - type: String, - required: true - }, - active: { - type: Boolean, - required: false - }, - external: { - type: Boolean, - required: false - }, - behavior: { - type: String, - required: false, - }, - }, - data() { - return { - }; - } -}); -</script> - -<style lang="scss" scoped> -.qmfkfnzi { - > .main { - display: flex; - align-items: center; - width: 100%; - box-sizing: border-box; - padding: 14px 16px 14px 14px; - - &:hover { - text-decoration: none; - } - - &.active { - color: var(--accent); - background: var(--panelHighlight); - } - - > .icon { - width: 32px; - margin-right: 2px; - flex-shrink: 0; - text-align: center; - opacity: 0.8; - - &:empty { - display: none; - - & + .text { - padding-left: 4px; - } - } - } - - > .text { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - padding-right: 12px; - } - - > .right { - margin-left: auto; - opacity: 0.7; - - > .text:not(:empty) { - margin-right: 0.75em; - } - } - } -} -</style> diff --git a/src/client/components/debobigego/object-view.vue b/src/client/components/debobigego/object-view.vue deleted file mode 100644 index ea79daa915..0000000000 --- a/src/client/components/debobigego/object-view.vue +++ /dev/null @@ -1,102 +0,0 @@ -<template> -<FormGroup class="_debobigegoItem"> - <template #label><slot></slot></template> - <div class="drooglns _debobigegoItem" :class="{ tall }"> - <div class="input _debobigegoPanel"> - <textarea class="_monospace" - v-model="v" - readonly - :spellcheck="false" - ></textarea> - </div> - </div> - <template #caption><slot name="desc"></slot></template> -</FormGroup> -</template> - -<script lang="ts"> -import { defineComponent, ref, toRefs, watch } from 'vue'; -import * as JSON5 from 'json5'; -import './debobigego.scss'; -import FormGroup from './group.vue'; - -export default defineComponent({ - components: { - FormGroup, - }, - props: { - value: { - required: false - }, - tall: { - type: Boolean, - required: false, - default: false - }, - pre: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - setup(props, context) { - const { value } = toRefs(props); - const v = ref(''); - - watch(() => value, newValue => { - v.value = JSON5.stringify(newValue.value, null, '\t'); - }, { - immediate: true - }); - - return { - v, - }; - } -}); -</script> - -<style lang="scss" scoped> -.drooglns { - position: relative; - - > .input { - position: relative; - - > textarea { - display: block; - width: 100%; - min-width: 100%; - max-width: 100%; - min-height: 130px; - margin: 0; - padding: 16px var(--debobigegoContentHMargin); - box-sizing: border-box; - font: inherit; - font-weight: normal; - font-size: 1em; - background: transparent; - border: none; - border-radius: 0; - outline: none; - box-shadow: none; - color: var(--fg); - tab-size: 2; - white-space: pre; - } - } - - &.tall { - > .input { - > textarea { - min-height: 200px; - } - } - } -} -</style> diff --git a/src/client/components/debobigego/pagination.vue b/src/client/components/debobigego/pagination.vue deleted file mode 100644 index 2166f5065f..0000000000 --- a/src/client/components/debobigego/pagination.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<FormGroup class="uljviswt _debobigegoItem"> - <template #label><slot name="label"></slot></template> - <slot :items="items"></slot> - <div class="empty" v-if="empty" key="_empty_"> - <slot name="empty"></slot> - </div> - <FormButton v-show="more" class="button" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </FormButton> -</FormGroup> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import FormButton from './button.vue'; -import FormGroup from './group.vue'; -import paging from '@client/scripts/paging'; - -export default defineComponent({ - components: { - FormButton, - FormGroup, - }, - - mixins: [ - paging({}), - ], - - props: { - pagination: { - required: true - }, - }, -}); -</script> - -<style lang="scss" scoped> -.uljviswt { -} -</style> diff --git a/src/client/components/debobigego/radios.vue b/src/client/components/debobigego/radios.vue deleted file mode 100644 index 071c013afb..0000000000 --- a/src/client/components/debobigego/radios.vue +++ /dev/null @@ -1,112 +0,0 @@ -<script lang="ts"> -import { defineComponent, h } from 'vue'; -import MkRadio from '@client/components/form/radio.vue'; -import './debobigego.scss'; - -export default defineComponent({ - components: { - MkRadio - }, - props: { - modelValue: { - required: false - }, - }, - data() { - return { - value: this.modelValue, - } - }, - watch: { - modelValue() { - this.value = this.modelValue; - }, - value() { - this.$emit('update:modelValue', this.value); - } - }, - render() { - const label = this.$slots.desc(); - let options = this.$slots.default(); - - // なぜかFragmentになることがあるため - if (options.length === 1 && options[0].props == null) options = options[0].children; - - return h('div', { - class: 'cnklmpwm _debobigegoItem' - }, [ - h('div', { - class: '_debobigegoLabel', - }, label), - ...options.map(option => h('button', { - class: '_button _debobigegoPanel _debobigegoClickable', - key: option.key, - onClick: () => this.value = option.props.value, - }, [h('span', { - class: ['check', { checked: this.value === option.props.value }], - }), option.children])) - ]); - } -}); -</script> - -<style lang="scss"> -.cnklmpwm { - > button { - display: block; - width: 100%; - box-sizing: border-box; - padding: 14px 18px; - text-align: left; - - &:not(:first-of-type) { - border-top: none !important; - border-top-left-radius: 0; - border-top-right-radius: 0; - } - - &:not(:last-of-type) { - border-bottom: solid 0.5px var(--divider); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - - > .check { - display: inline-block; - vertical-align: bottom; - position: relative; - width: 16px; - height: 16px; - margin-right: 8px; - background: none; - border: 2px solid var(--inputBorder); - border-radius: 100%; - transition: inherit; - - &:after { - content: ""; - display: block; - position: absolute; - top: 3px; - right: 3px; - bottom: 3px; - left: 3px; - border-radius: 100%; - opacity: 0; - transform: scale(0); - transition: .4s cubic-bezier(.25,.8,.25,1); - } - - &.checked { - border-color: var(--accent); - - &:after { - background-color: var(--accent); - transform: scale(1); - opacity: 1; - } - } - } - } -} -</style> diff --git a/src/client/components/debobigego/range.vue b/src/client/components/debobigego/range.vue deleted file mode 100644 index 26fb0f37c6..0000000000 --- a/src/client/components/debobigego/range.vue +++ /dev/null @@ -1,122 +0,0 @@ -<template> -<div class="ifitouly _debobigegoItem" :class="{ focused, disabled }"> - <div class="_debobigegoLabel"><slot name="label"></slot></div> - <div class="_debobigegoPanel main"> - <input - type="range" - ref="input" - v-model="v" - :disabled="disabled" - :min="min" - :max="max" - :step="step" - @focus="focused = true" - @blur="focused = false" - @input="$emit('update:value', $event.target.value)" - /> - </div> - <div class="_debobigegoCaption"><slot name="caption"></slot></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - value: { - type: Number, - required: false, - default: 0 - }, - disabled: { - type: Boolean, - required: false, - default: false - }, - min: { - type: Number, - required: false, - default: 0 - }, - max: { - type: Number, - required: false, - default: 100 - }, - step: { - type: Number, - required: false, - default: 1 - }, - }, - data() { - return { - v: this.value, - focused: false - }; - }, - watch: { - value(v) { - this.v = parseFloat(v); - } - }, -}); -</script> - -<style lang="scss" scoped> -.ifitouly { - position: relative; - - > .main { - padding: 22px 16px; - - > input { - display: block; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: var(--X10); - height: 4px; - width: 100%; - box-sizing: border-box; - margin: 0; - outline: 0; - border: 0; - border-radius: 7px; - - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - box-sizing: content-box; - } - - &::-moz-range-thumb { - -moz-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - } - } - } -} -</style> diff --git a/src/client/components/debobigego/select.vue b/src/client/components/debobigego/select.vue deleted file mode 100644 index 7a31371afc..0000000000 --- a/src/client/components/debobigego/select.vue +++ /dev/null @@ -1,145 +0,0 @@ -<template> -<div class="yrtfrpux _debobigegoItem" :class="{ disabled, inline }"> - <div class="_debobigegoLabel"><slot name="label"></slot></div> - <div class="icon" ref="icon"><slot name="icon"></slot></div> - <div class="input _debobigegoPanel _debobigegoClickable" @click="focus"> - <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> - <select ref="input" - v-model="v" - :required="required" - :disabled="disabled" - @focus="focused = true" - @blur="focused = false" - > - <slot></slot> - </select> - <div class="suffix"> - <i class="fas fa-chevron-down"></i> - </div> - </div> - <div class="_debobigegoCaption"><slot name="caption"></slot></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import './debobigego.scss'; - -export default defineComponent({ - props: { - modelValue: { - required: false - }, - required: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - }, - data() { - return { - }; - }, - computed: { - v: { - get() { - return this.modelValue; - }, - set(v) { - this.$emit('update:modelValue', v); - } - }, - }, - methods: { - focus() { - this.$refs.input.focus(); - } - } -}); -</script> - -<style lang="scss" scoped> -.yrtfrpux { - position: relative; - - > .icon { - position: absolute; - top: 0; - left: 0; - width: 24px; - text-align: center; - line-height: 32px; - - &:not(:empty) + .input { - margin-left: 28px; - } - } - - > .input { - display: flex; - position: relative; - - > select { - display: block; - flex: 1; - width: 100%; - padding: 0 16px; - font: inherit; - font-weight: normal; - font-size: 1em; - height: 48px; - background: none; - border: none; - border-radius: 0; - outline: none; - box-shadow: none; - appearance: none; - -webkit-appearance: none; - color: var(--fg); - - option, - optgroup { - color: var(--fg); - background: var(--bg); - } - } - - > .prefix, - > .suffix { - display: block; - align-self: center; - justify-self: center; - font-size: 1em; - line-height: 32px; - color: var(--inputLabel); - pointer-events: none; - - &:empty { - display: none; - } - - > * { - display: block; - min-width: 16px; - } - } - - > .prefix { - padding-right: 4px; - } - - > .suffix { - padding: 0 16px 0 0; - opacity: 0.7; - } - } -} -</style> diff --git a/src/client/components/debobigego/suspense.vue b/src/client/components/debobigego/suspense.vue deleted file mode 100644 index e59e0ba12d..0000000000 --- a/src/client/components/debobigego/suspense.vue +++ /dev/null @@ -1,101 +0,0 @@ -<template> -<transition name="fade" mode="out-in"> - <div class="_debobigegoItem" v-if="pending"> - <div class="_debobigegoPanel"> - <MkLoading/> - </div> - </div> - <div v-else-if="resolved" class="_debobigegoItem"> - <slot :result="result"></slot> - </div> - <div class="_debobigegoItem" v-else> - <div class="_debobigegoPanel eiurkvay"> - <div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div> - <MkButton inline @click="retry" class="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton> - </div> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent, PropType, ref, watch } from 'vue'; -import './debobigego.scss'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - MkButton - }, - - props: { - p: { - type: Function as PropType<() => Promise<any>>, - required: true, - } - }, - - setup(props, context) { - const pending = ref(true); - const resolved = ref(false); - const rejected = ref(false); - const result = ref(null); - - const process = () => { - if (props.p == null) { - return; - } - const promise = props.p(); - pending.value = true; - resolved.value = false; - rejected.value = false; - promise.then((_result) => { - pending.value = false; - resolved.value = true; - result.value = _result; - }); - promise.catch(() => { - pending.value = false; - rejected.value = true; - }); - }; - - watch(() => props.p, () => { - process(); - }, { - immediate: true - }); - - const retry = () => { - process(); - }; - - return { - pending, - resolved, - rejected, - result, - retry, - }; - } -}); -</script> - -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} - -.eiurkvay { - padding: 16px; - text-align: center; - - > .retry { - margin-top: 16px; - } -} -</style> diff --git a/src/client/components/debobigego/switch.vue b/src/client/components/debobigego/switch.vue deleted file mode 100644 index 9a69e18302..0000000000 --- a/src/client/components/debobigego/switch.vue +++ /dev/null @@ -1,132 +0,0 @@ -<template> -<div class="ijnpvmgr _debobigegoItem"> - <div class="main _debobigegoPanel _debobigegoClickable" - :class="{ disabled, checked }" - :aria-checked="checked" - :aria-disabled="disabled" - @click.prevent="toggle" - > - <input - type="checkbox" - ref="input" - :disabled="disabled" - @keydown.enter="toggle" - > - <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> - <span class="handle"></span> - </span> - <span class="label"> - <span><slot></slot></span> - </span> - </div> - <div class="_debobigegoCaption"><slot name="desc"></slot></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import './debobigego.scss'; - -export default defineComponent({ - props: { - modelValue: { - type: Boolean, - default: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.modelValue; - } - }, - methods: { - toggle() { - if (this.disabled) return; - this.$emit('update:modelValue', !this.checked); - } - } -}); -</script> - -<style lang="scss" scoped> -.ijnpvmgr { - > .main { - position: relative; - display: flex; - padding: 14px 16px; - cursor: pointer; - - > * { - user-select: none; - } - - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; - } - - > .button { - position: relative; - display: inline-block; - flex-shrink: 0; - margin: 0; - width: 34px; - height: 22px; - background: var(--switchBg); - outline: none; - border-radius: 999px; - transition: all 0.3s; - cursor: pointer; - - > .handle { - position: absolute; - top: 0; - left: 3px; - bottom: 0; - margin: auto 0; - border-radius: 100%; - transition: background-color 0.3s, transform 0.3s; - width: 16px; - height: 16px; - background-color: #fff; - pointer-events: none; - } - } - - > .label { - margin-left: 12px; - display: block; - transition: inherit; - color: var(--fg); - - > span { - display: block; - line-height: 20px; - transition: inherit; - } - } - - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &.checked { - > .button { - background-color: var(--accent); - - > .handle { - transform: translateX(12px); - } - } - } - } -} -</style> diff --git a/src/client/components/debobigego/textarea.vue b/src/client/components/debobigego/textarea.vue deleted file mode 100644 index 64e8d47126..0000000000 --- a/src/client/components/debobigego/textarea.vue +++ /dev/null @@ -1,161 +0,0 @@ -<template> -<FormGroup class="_debobigegoItem"> - <template #label><slot></slot></template> - <div class="rivhosbp _debobigegoItem" :class="{ tall, pre }"> - <div class="input _debobigegoPanel"> - <textarea ref="input" :class="{ code, _monospace: code }" - v-model="v" - :required="required" - :readonly="readonly" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="!code" - @input="onInput" - @focus="focused = true" - @blur="focused = false" - ></textarea> - </div> - </div> - <template #caption><slot name="desc"></slot></template> - - <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> -</FormGroup> -</template> - -<script lang="ts"> -import { defineComponent, ref, toRefs, watch } from 'vue'; -import './debobigego.scss'; -import FormButton from './button.vue'; -import FormGroup from './group.vue'; - -export default defineComponent({ - components: { - FormGroup, - FormButton, - }, - props: { - modelValue: { - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - autocomplete: { - type: String, - required: false - }, - code: { - type: Boolean, - required: false - }, - tall: { - type: Boolean, - required: false, - default: false - }, - pre: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - setup(props, context) { - const { modelValue } = toRefs(props); - const v = ref(modelValue.value); - const changed = ref(false); - const inputEl = ref(null); - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; - - watch(modelValue.value, newValue => { - v.value = newValue; - }); - - watch(v, newValue => { - if (!props.manualSave) { - updated(); - } - }); - - return { - v, - updated, - changed, - focus, - onInput, - }; - } -}); -</script> - -<style lang="scss" scoped> -.rivhosbp { - position: relative; - - > .input { - position: relative; - - > textarea { - display: block; - width: 100%; - min-width: 100%; - max-width: 100%; - min-height: 130px; - margin: 0; - padding: 16px; - box-sizing: border-box; - font: inherit; - font-weight: normal; - font-size: 1em; - background: transparent; - border: none; - border-radius: 0; - outline: none; - box-shadow: none; - color: var(--fg); - - &.code { - tab-size: 2; - } - } - } - - &.tall { - > .input { - > textarea { - min-height: 200px; - } - } - } - - &.pre { - > .input { - > textarea { - white-space: pre; - } - } - } -} -</style> diff --git a/src/client/components/debobigego/tuple.vue b/src/client/components/debobigego/tuple.vue deleted file mode 100644 index 8a4599fd64..0000000000 --- a/src/client/components/debobigego/tuple.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> -<div class="wthhikgt _debobigegoItem" v-size="{ max: [500] }"> - <slot></slot> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ -}); -</script> - -<style lang="scss" scoped> -.wthhikgt { - position: relative; - display: flex; - - > ::v-deep(*) { - flex: 1; - margin: 0; - - &:not(:last-child) { - margin-right: 16px; - } - } - - &.max-width_500px { - display: block; - - > ::v-deep(*) { - margin: inherit; - } - } -} -</style> diff --git a/src/client/components/dialog.vue b/src/client/components/dialog.vue deleted file mode 100644 index dd4932f61f..0000000000 --- a/src/client/components/dialog.vue +++ /dev/null @@ -1,212 +0,0 @@ -<template> -<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> - <div class="mk-dialog"> - <div class="icon" v-if="icon"> - <i :class="icon"></i> - </div> - <div class="icon" v-else-if="!input && !select" :class="type"> - <i v-if="type === 'success'" class="fas fa-check"></i> - <i v-else-if="type === 'error'" class="fas fa-times-circle"></i> - <i v-else-if="type === 'warning'" class="fas fa-exclamation-triangle"></i> - <i v-else-if="type === 'info'" class="fas fa-info-circle"></i> - <i v-else-if="type === 'question'" class="fas fa-question-circle"></i> - <i v-else-if="type === 'waiting'" class="fas fa-spinner fa-pulse"></i> - </div> - <header v-if="title"><Mfm :text="title"/></header> - <div class="body" v-if="text"><Mfm :text="text"/></div> - <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></MkInput> - <MkSelect v-if="select" v-model="selectedValue" autofocus> - <template v-if="select.items"> - <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> - </template> - <template v-else> - <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label"> - <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option> - </optgroup> - </template> - </MkSelect> - <div class="buttons" v-if="(showOkButton || showCancelButton) && !actions"> - <MkButton inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select">{{ (showCancelButton || input || select) ? $ts.ok : $ts.gotIt }}</MkButton> - <MkButton inline @click="cancel" v-if="showCancelButton || input || select">{{ $ts.cancel }}</MkButton> - </div> - <div class="buttons" v-if="actions"> - <MkButton v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</MkButton> - </div> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from '@client/components/ui/modal.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/components/form/select.vue'; - -export default defineComponent({ - components: { - MkModal, - MkButton, - MkInput, - MkSelect, - }, - - props: { - type: { - type: String, - required: false, - default: 'info' - }, - title: { - type: String, - required: false - }, - text: { - type: String, - required: false - }, - input: { - required: false - }, - select: { - required: false - }, - icon: { - required: false - }, - actions: { - required: false - }, - showOkButton: { - type: Boolean, - default: true - }, - showCancelButton: { - type: Boolean, - default: false - }, - cancelableByBgClick: { - type: Boolean, - default: true - }, - }, - - emits: ['done', 'closed'], - - data() { - return { - inputValue: this.input && this.input.default ? this.input.default : null, - selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null, - }; - }, - - mounted() { - document.addEventListener('keydown', this.onKeydown); - }, - - beforeUnmount() { - document.removeEventListener('keydown', this.onKeydown); - }, - - methods: { - done(canceled, result?) { - this.$emit('done', { canceled, result }); - this.$refs.modal.close(); - }, - - async ok() { - if (!this.showOkButton) return; - - const result = - this.input ? this.inputValue : - this.select ? this.selectedValue : - true; - this.done(false, result); - }, - - cancel() { - this.done(true); - }, - - onBgClick() { - if (this.cancelableByBgClick) { - this.cancel(); - } - }, - - onKeydown(e) { - if (e.which === 27) { // ESC - this.cancel(); - } - }, - - onInputKeydown(e) { - if (e.which === 13) { // Enter - e.preventDefault(); - e.stopPropagation(); - this.ok(); - } - } - } -}); -</script> - -<style lang="scss" scoped> -.mk-dialog { - position: relative; - padding: 32px; - min-width: 320px; - max-width: 480px; - box-sizing: border-box; - text-align: center; - background: var(--panel); - border-radius: var(--radius); - - > .icon { - font-size: 32px; - - &.success { - color: var(--success); - } - - &.error { - color: var(--error); - } - - &.warning { - color: var(--warn); - } - - > * { - display: block; - margin: 0 auto; - } - - & + header { - margin-top: 16px; - } - } - - > header { - margin: 0 0 8px 0; - font-weight: bold; - font-size: 20px; - - & + .body { - margin-top: 8px; - } - } - - > .body { - margin: 16px 0 0 0; - } - - > .buttons { - margin-top: 16px; - - > * { - margin: 0 8px; - } - } -} -</style> diff --git a/src/client/components/drive-file-thumbnail.vue b/src/client/components/drive-file-thumbnail.vue deleted file mode 100644 index 2cb1d98618..0000000000 --- a/src/client/components/drive-file-thumbnail.vue +++ /dev/null @@ -1,108 +0,0 @@ -<template> -<div class="zdjebgpv" ref="thumbnail"> - <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/> - <i v-else-if="is === 'image'" class="fas fa-file-image icon"></i> - <i v-else-if="is === 'video'" class="fas fa-file-video icon"></i> - <i v-else-if="is === 'audio' || is === 'midi'" class="fas fa-music icon"></i> - <i v-else-if="is === 'csv'" class="fas fa-file-csv icon"></i> - <i v-else-if="is === 'pdf'" class="fas fa-file-pdf icon"></i> - <i v-else-if="is === 'textfile'" class="fas fa-file-alt icon"></i> - <i v-else-if="is === 'archive'" class="fas fa-file-archive icon"></i> - <i v-else class="fas fa-file icon"></i> - - <i v-if="isThumbnailAvailable && is === 'video'" class="fas fa-film icon-sub"></i> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; -import { ColdDeviceStorage } from '@client/store'; - -export default defineComponent({ - components: { - ImgWithBlurhash - }, - props: { - file: { - type: Object, - required: true - }, - fit: { - type: String, - required: false, - default: 'cover' - }, - }, - data() { - return { - isContextmenuShowing: false, - isDragging: false, - - }; - }, - computed: { - is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' { - if (this.file.type.startsWith('image/')) return 'image'; - if (this.file.type.startsWith('video/')) return 'video'; - if (this.file.type === 'audio/midi') return 'midi'; - if (this.file.type.startsWith('audio/')) return 'audio'; - if (this.file.type.endsWith('/csv')) return 'csv'; - if (this.file.type.endsWith('/pdf')) return 'pdf'; - if (this.file.type.startsWith('text/')) return 'textfile'; - if ([ - "application/zip", - "application/x-cpio", - "application/x-bzip", - "application/x-bzip2", - "application/java-archive", - "application/x-rar-compressed", - "application/x-tar", - "application/gzip", - "application/x-7z-compressed" - ].some(e => e === this.file.type)) return 'archive'; - return 'unknown'; - }, - isThumbnailAvailable(): boolean { - return this.file.thumbnailUrl - ? (this.is === 'image' || this.is === 'video') - : false; - }, - }, - mounted() { - const audioTag = this.$refs.volumectrl as HTMLAudioElement; - if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); - }, - methods: { - volumechange() { - const audioTag = this.$refs.volumectrl as HTMLAudioElement; - ColdDeviceStorage.set('mediaVolume', audioTag.volume); - } - } -}); -</script> - -<style lang="scss" scoped> -.zdjebgpv { - position: relative; - - > .icon-sub { - position: absolute; - width: 30%; - height: auto; - margin: 0; - right: 4%; - bottom: 4%; - } - - > * { - margin: auto; - } - - > .icon { - pointer-events: none; - height: 65%; - width: 65%; - } -} -</style> diff --git a/src/client/components/drive-select-dialog.vue b/src/client/components/drive-select-dialog.vue deleted file mode 100644 index ce6e2fa789..0000000000 --- a/src/client/components/drive-select-dialog.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="800" - :height="500" - :with-ok-button="true" - :ok-button-disabled="(type === 'file') && (selected.length === 0)" - @click="cancel()" - @close="cancel()" - @ok="ok()" - @closed="$emit('closed')" -> - <template #header> - {{ multiple ? ((type === 'file') ? $ts.selectFiles : $ts.selectFolders) : ((type === 'file') ? $ts.selectFile : $ts.selectFolder) }} - <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> - </template> - <XDrive :multiple="multiple" @changeSelection="onChangeSelection" @selected="ok()" :select="type"/> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XDrive from './drive.vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import number from '@client/filters/number'; - -export default defineComponent({ - components: { - XDrive, - XModalWindow, - }, - - props: { - type: { - type: String, - required: false, - default: 'file' - }, - multiple: { - type: Boolean, - default: false - } - }, - - emits: ['done', 'closed'], - - data() { - return { - selected: [] - }; - }, - - methods: { - ok() { - this.$emit('done', this.selected); - this.$refs.dialog.close(); - }, - - cancel() { - this.$emit('done'); - this.$refs.dialog.close(); - }, - - onChangeSelection(xs) { - this.selected = xs; - }, - - number - } -}); -</script> diff --git a/src/client/components/drive-window.vue b/src/client/components/drive-window.vue deleted file mode 100644 index 30b04091be..0000000000 --- a/src/client/components/drive-window.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> -<XWindow ref="window" - :initial-width="800" - :initial-height="500" - :can-resize="true" - @closed="$emit('closed')" -> - <template #header> - {{ $ts.drive }} - </template> - <XDrive :initial-folder="initialFolder"/> -</XWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XDrive from './drive.vue'; -import XWindow from '@client/components/ui/window.vue'; - -export default defineComponent({ - components: { - XDrive, - XWindow, - }, - - props: { - initialFolder: { - type: Object, - required: false - }, - }, - - emits: ['closed'], - - data() { - return { - }; - }, - - methods: { - - } -}); -</script> diff --git a/src/client/components/drive.file.vue b/src/client/components/drive.file.vue deleted file mode 100644 index b1be3d0cab..0000000000 --- a/src/client/components/drive.file.vue +++ /dev/null @@ -1,374 +0,0 @@ -<template> -<div class="ncvczrfv" - :class="{ isSelected }" - @click="onClick" - @contextmenu.stop="onContextmenu" - draggable="true" - @dragstart="onDragstart" - @dragend="onDragend" - :title="title" -> - <div class="label" v-if="$i.avatarId == file.id"> - <img src="/static-assets/client/label.svg"/> - <p>{{ $ts.avatar }}</p> - </div> - <div class="label" v-if="$i.bannerId == file.id"> - <img src="/static-assets/client/label.svg"/> - <p>{{ $ts.banner }}</p> - </div> - <div class="label red" v-if="file.isSensitive"> - <img src="/static-assets/client/label-red.svg"/> - <p>{{ $ts.nsfw }}</p> - </div> - - <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> - - <p class="name"> - <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> - <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> - </p> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import MkDriveFileThumbnail from './drive-file-thumbnail.vue'; -import bytes from '@client/filters/bytes'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkDriveFileThumbnail - }, - - props: { - file: { - type: Object, - required: true, - }, - isSelected: { - type: Boolean, - required: false, - default: false, - }, - selectMode: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['chosen'], - - data() { - return { - isDragging: false - }; - }, - - computed: { - // TODO: parentへの参照を無くす - browser(): any { - return this.$parent; - }, - title(): string { - return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`; - } - }, - - methods: { - getMenu() { - return [{ - text: this.$ts.rename, - icon: 'fas fa-i-cursor', - action: this.rename - }, { - text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, - icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', - action: this.toggleSensitive - }, { - text: this.$ts.describeFile, - icon: 'fas fa-i-cursor', - action: this.describe - }, null, { - text: this.$ts.copyUrl, - icon: 'fas fa-link', - action: this.copyUrl - }, { - type: 'a', - href: this.file.url, - target: '_blank', - text: this.$ts.download, - icon: 'fas fa-download', - download: this.file.name - }, null, { - text: this.$ts.delete, - icon: 'fas fa-trash-alt', - danger: true, - action: this.deleteFile - }]; - }, - - onClick(ev) { - if (this.selectMode) { - this.$emit('chosen', this.file); - } else { - os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); - } - }, - - onContextmenu(e) { - os.contextMenu(this.getMenu(), e); - }, - - onDragstart(e) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file)); - this.isDragging = true; - - // 親ブラウザに対して、ドラッグが開始されたフラグを立てる - // (=あなたの子供が、ドラッグを開始しましたよ) - this.browser.isDragSource = true; - }, - - onDragend(e) { - this.isDragging = false; - this.browser.isDragSource = false; - }, - - rename() { - os.dialog({ - title: this.$ts.renameFile, - input: { - placeholder: this.$ts.inputNewFileName, - default: this.file.name, - allowEmpty: false - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/files/update', { - fileId: this.file.id, - name: name - }); - }); - }, - - describe() { - os.popup(import('@client/components/media-caption.vue'), { - title: this.$ts.describeFile, - input: { - placeholder: this.$ts.inputNewDescription, - default: this.file.comment !== null ? this.file.comment : '', - }, - image: this.file - }, { - done: result => { - if (!result || result.canceled) return; - let comment = result.result; - os.api('drive/files/update', { - fileId: this.file.id, - comment: comment.length == 0 ? null : comment - }); - } - }, 'closed'); - }, - - toggleSensitive() { - os.api('drive/files/update', { - fileId: this.file.id, - isSensitive: !this.file.isSensitive - }); - }, - - copyUrl() { - copyToClipboard(this.file.url); - os.success(); - }, - - setAsAvatar() { - os.updateAvatar(this.file); - }, - - setAsBanner() { - os.updateBanner(this.file); - }, - - addApp() { - alert('not implemented yet'); - }, - - async deleteFile() { - const { canceled } = await os.dialog({ - type: 'warning', - text: this.$t('driveFileDeleteConfirm', { name: this.file.name }), - showCancelButton: true - }); - if (canceled) return; - - os.api('drive/files/delete', { - fileId: this.file.id - }); - }, - - bytes - } -}); -</script> - -<style lang="scss" scoped> -.ncvczrfv { - position: relative; - padding: 8px 0 0 0; - min-height: 180px; - border-radius: 4px; - - &, * { - cursor: pointer; - } - - > * { - pointer-events: none; - } - - &:hover { - background: rgba(#000, 0.05); - - > .label { - &:before, - &:after { - background: #0b65a5; - } - - &.red { - &:before, - &:after { - background: #c12113; - } - } - } - } - - &:active { - background: rgba(#000, 0.1); - - > .label { - &:before, - &:after { - background: #0b588c; - } - - &.red { - &:before, - &:after { - background: #ce2212; - } - } - } - } - - &.isSelected { - background: var(--accent); - - &:hover { - background: var(--accentLighten); - } - - &:active { - background: var(--accentDarken); - } - - > .label { - &:before, - &:after { - display: none; - } - } - - > .name { - color: #fff; - } - - > .thumbnail { - color: #fff; - } - } - - > .label { - position: absolute; - top: 0; - left: 0; - pointer-events: none; - - &:before, - &:after { - content: ""; - display: block; - position: absolute; - z-index: 1; - background: #0c7ac9; - } - - &:before { - top: 0; - left: 57px; - width: 28px; - height: 8px; - } - - &:after { - top: 57px; - left: 0; - width: 8px; - height: 28px; - } - - &.red { - &:before, - &:after { - background: #c12113; - } - } - - > img { - position: absolute; - z-index: 2; - top: 0; - left: 0; - } - - > p { - position: absolute; - z-index: 3; - top: 19px; - left: -28px; - width: 120px; - margin: 0; - text-align: center; - line-height: 28px; - color: #fff; - transform: rotate(-45deg); - } - } - - > .thumbnail { - width: 110px; - height: 110px; - margin: auto; - } - - > .name { - display: block; - margin: 4px 0 0 0; - font-size: 0.8em; - text-align: center; - word-break: break-all; - color: var(--fg); - overflow: hidden; - - > .ext { - opacity: 0.5; - } - } -} -</style> diff --git a/src/client/components/drive.folder.vue b/src/client/components/drive.folder.vue deleted file mode 100644 index 4c09e7775a..0000000000 --- a/src/client/components/drive.folder.vue +++ /dev/null @@ -1,326 +0,0 @@ -<template> -<div class="rghtznwe" - :class="{ draghover }" - @click="onClick" - @contextmenu.stop="onContextmenu" - @mouseover="onMouseover" - @mouseout="onMouseout" - @dragover.prevent.stop="onDragover" - @dragenter.prevent="onDragenter" - @dragleave="onDragleave" - @drop.prevent.stop="onDrop" - draggable="true" - @dragstart="onDragstart" - @dragend="onDragend" - :title="title" -> - <p class="name"> - <template v-if="hover"><i class="fas fa-folder-open fa-fw"></i></template> - <template v-if="!hover"><i class="fas fa-folder fa-fw"></i></template> - {{ folder.name }} - </p> - <p class="upload" v-if="$store.state.uploadFolder == folder.id"> - {{ $ts.uploadFolder }} - </p> - <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - folder: { - type: Object, - required: true, - }, - isSelected: { - type: Boolean, - required: false, - default: false, - }, - selectMode: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['chosen'], - - data() { - return { - hover: false, - draghover: false, - isDragging: false, - }; - }, - - computed: { - browser(): any { - return this.$parent; - }, - title(): string { - return this.folder.name; - } - }, - - methods: { - checkboxClicked(e) { - this.$emit('chosen', this.folder); - }, - - onClick() { - this.browser.move(this.folder); - }, - - onMouseover() { - this.hover = true; - }, - - onMouseout() { - this.hover = false - }, - - onDragover(e) { - // 自分自身がドラッグされている場合 - if (this.isDragging) { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - return; - } - - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; - - if (isFile || isDriveFile || isDriveFolder) { - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } else { - e.dataTransfer.dropEffect = 'none'; - } - }, - - onDragenter() { - if (!this.isDragging) this.draghover = true; - }, - - onDragleave() { - this.draghover = false; - }, - - onDrop(e) { - this.draghover = false; - - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - for (const file of Array.from(e.dataTransfer.files)) { - this.browser.upload(file, this.folder); - } - return; - } - - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - this.browser.removeFile(file.id); - os.api('drive/files/update', { - fileId: file.id, - folderId: this.folder.id - }); - } - //#endregion - - //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder != '') { - const folder = JSON.parse(driveFolder); - - // 移動先が自分自身ならreject - if (folder.id == this.folder.id) return; - - this.browser.removeFolder(folder.id); - os.api('drive/folders/update', { - folderId: folder.id, - parentId: this.folder.id - }).then(() => { - // noop - }).catch(err => { - switch (err) { - case 'detected-circular-definition': - os.dialog({ - title: this.$ts.unableToProcess, - text: this.$ts.circularReferenceFolder - }); - break; - default: - os.dialog({ - type: 'error', - text: this.$ts.somethingHappened - }); - } - }); - } - //#endregion - }, - - onDragstart(e) { - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(this.folder)); - this.isDragging = true; - - // 親ブラウザに対して、ドラッグが開始されたフラグを立てる - // (=あなたの子供が、ドラッグを開始しましたよ) - this.browser.isDragSource = true; - }, - - onDragend() { - this.isDragging = false; - this.browser.isDragSource = false; - }, - - go() { - this.browser.move(this.folder.id); - }, - - newWindow() { - this.browser.newWindow(this.folder); - }, - - rename() { - os.dialog({ - title: this.$ts.renameFolder, - input: { - placeholder: this.$ts.inputNewFolderName, - default: this.folder.name - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/folders/update', { - folderId: this.folder.id, - name: name - }); - }); - }, - - deleteFolder() { - os.api('drive/folders/delete', { - folderId: this.folder.id - }).then(() => { - if (this.$store.state.uploadFolder === this.folder.id) { - this.$store.set('uploadFolder', null); - } - }).catch(err => { - switch(err.id) { - case 'b0fc8a17-963c-405d-bfbc-859a487295e1': - os.dialog({ - type: 'error', - title: this.$ts.unableToDelete, - text: this.$ts.hasChildFilesOrFolders - }); - break; - default: - os.dialog({ - type: 'error', - text: this.$ts.unableToDelete - }); - } - }); - }, - - setAsUploadFolder() { - this.$store.set('uploadFolder', this.folder.id); - }, - - onContextmenu(e) { - os.contextMenu([{ - text: this.$ts.openInWindow, - icon: 'fas fa-window-restore', - action: () => { - os.popup(import('./drive-window.vue'), { - initialFolder: this.folder - }, { - }, 'closed'); - } - }, null, { - text: this.$ts.rename, - icon: 'fas fa-i-cursor', - action: this.rename - }, null, { - text: this.$ts.delete, - icon: 'fas fa-trash-alt', - danger: true, - action: this.deleteFolder - }], e); - }, - } -}); -</script> - -<style lang="scss" scoped> -.rghtznwe { - position: relative; - padding: 8px; - height: 64px; - background: var(--driveFolderBg); - border-radius: 4px; - - &, * { - cursor: pointer; - } - - *:not(.checkbox) { - pointer-events: none; - } - - > .checkbox { - position: absolute; - bottom: 8px; - right: 8px; - width: 16px; - height: 16px; - background: #fff; - border: solid 1px #000; - - &.checked { - background: var(--accent); - } - } - - &.draghover { - &:after { - content: ""; - pointer-events: none; - position: absolute; - top: -4px; - right: -4px; - bottom: -4px; - left: -4px; - border: 2px dashed var(--focus); - border-radius: 4px; - } - } - - > .name { - margin: 0; - font-size: 0.9em; - color: var(--desktopDriveFolderFg); - - > i { - margin-right: 4px; - margin-left: 2px; - text-align: left; - } - } - - > .upload { - margin: 4px 4px; - font-size: 0.8em; - text-align: right; - color: var(--desktopDriveFolderFg); - } -} -</style> diff --git a/src/client/components/drive.nav-folder.vue b/src/client/components/drive.nav-folder.vue deleted file mode 100644 index 913a1b5f92..0000000000 --- a/src/client/components/drive.nav-folder.vue +++ /dev/null @@ -1,135 +0,0 @@ -<template> -<div class="drylbebk" - :class="{ draghover }" - @click="onClick" - @dragover.prevent.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.stop="onDrop" -> - <i v-if="folder == null" class="fas fa-cloud"></i> - <span>{{ folder == null ? $ts.drive : folder.name }}</span> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - folder: { - type: Object, - required: false, - } - }, - - data() { - return { - hover: false, - draghover: false, - }; - }, - - computed: { - browser(): any { - return this.$parent; - } - }, - - methods: { - onClick() { - this.browser.move(this.folder); - }, - - onMouseover() { - this.hover = true; - }, - - onMouseout() { - this.hover = false; - }, - - onDragover(e) { - // このフォルダがルートかつカレントディレクトリならドロップ禁止 - if (this.folder == null && this.browser.folder == null) { - e.dataTransfer.dropEffect = 'none'; - } - - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; - - if (isFile || isDriveFile || isDriveFolder) { - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } else { - e.dataTransfer.dropEffect = 'none'; - } - - return false; - }, - - onDragenter() { - if (this.folder || this.browser.folder) this.draghover = true; - }, - - onDragleave() { - if (this.folder || this.browser.folder) this.draghover = false; - }, - - onDrop(e) { - this.draghover = false; - - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - for (const file of Array.from(e.dataTransfer.files)) { - this.browser.upload(file, this.folder); - } - return; - } - - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - this.browser.removeFile(file.id); - os.api('drive/files/update', { - fileId: file.id, - folderId: this.folder ? this.folder.id : null - }); - } - //#endregion - - //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder != '') { - const folder = JSON.parse(driveFolder); - // 移動先が自分自身ならreject - if (this.folder && folder.id == this.folder.id) return; - this.browser.removeFolder(folder.id); - os.api('drive/folders/update', { - folderId: folder.id, - parentId: this.folder ? this.folder.id : null - }); - } - //#endregion - } - } -}); -</script> - -<style lang="scss" scoped> -.drylbebk { - > * { - pointer-events: none; - } - - &.draghover { - background: #eee; - } - - > i { - margin-right: 4px; - } -} -</style> diff --git a/src/client/components/drive.vue b/src/client/components/drive.vue deleted file mode 100644 index 5dadf9a11f..0000000000 --- a/src/client/components/drive.vue +++ /dev/null @@ -1,784 +0,0 @@ -<template> -<div class="yfudmmck"> - <nav> - <div class="path" @contextmenu.prevent.stop="() => {}"> - <XNavFolder :class="{ current: folder == null }"/> - <template v-for="f in hierarchyFolders"> - <span class="separator"><i class="fas fa-angle-right"></i></span> - <XNavFolder :folder="f"/> - </template> - <span class="separator" v-if="folder != null"><i class="fas fa-angle-right"></i></span> - <span class="folder current" v-if="folder != null">{{ folder.name }}</span> - </div> - <button @click="showMenu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button> - </nav> - <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" - ref="main" - @dragover.prevent.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.prevent.stop="onDrop" - @contextmenu.stop="onContextmenu" - > - <div class="contents" ref="contents"> - <div class="folders" ref="foldersContainer" v-show="folders.length > 0"> - <XFolder v-for="(f, i) in folders" :key="f.id" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" v-anim="i"/> - <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div class="padding" v-for="(n, i) in 16" :key="i"></div> - <MkButton ref="moreFolders" v-if="moreFolders">{{ $ts.loadMore }}</MkButton> - </div> - <div class="files" ref="filesContainer" v-show="files.length > 0"> - <XFile v-for="(file, i) in files" :key="file.id" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" v-anim="i"/> - <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> - <div class="padding" v-for="(n, i) in 16" :key="i"></div> - <MkButton ref="loadMoreFiles" @click="fetchMoreFiles" v-show="moreFiles">{{ $ts.loadMore }}</MkButton> - </div> - <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> - <p v-if="draghover">{{ $t('empty-draghover') }}</p> - <p v-if="!draghover && folder == null"><strong>{{ $ts.emptyDrive }}</strong><br/>{{ $t('empty-drive-description') }}</p> - <p v-if="!draghover && folder != null">{{ $ts.emptyFolder }}</p> - </div> - </div> - <MkLoading v-if="fetching"/> - </div> - <div class="dropzone" v-if="draghover"></div> - <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import XNavFolder from './drive.nav-folder.vue'; -import XFolder from './drive.folder.vue'; -import XFile from './drive.file.vue'; -import MkButton from './ui/button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XNavFolder, - XFolder, - XFile, - MkButton, - }, - - props: { - initialFolder: { - type: Object, - required: false - }, - type: { - type: String, - required: false, - default: undefined - }, - multiple: { - type: Boolean, - required: false, - default: false - }, - select: { - type: String, - required: false, - default: null - } - }, - - emits: ['selected', 'change-selection', 'move-root', 'cd', 'open-folder'], - - data() { - return { - /** - * 現在の階層(フォルダ) - * * null でルートを表す - */ - folder: null, - - files: [], - folders: [], - moreFiles: false, - moreFolders: false, - hierarchyFolders: [], - selectedFiles: [], - selectedFolders: [], - uploadings: os.uploads, - connection: null, - - /** - * ドロップされようとしているか - */ - draghover: false, - - /** - * 自信の所有するアイテムがドラッグをスタートさせたか - * (自分自身の階層にドロップできないようにするためのフラグ) - */ - isDragSource: false, - - fetching: true, - - ilFilesObserver: new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) - && !this.fetching && this.moreFiles && - this.fetchMoreFiles() - ), - moreFilesElement: null as Element, - - }; - }, - - watch: { - folder() { - this.$emit('cd', this.folder); - } - }, - - mounted() { - if (this.$store.state.enableInfiniteScroll && this.$refs.loadMoreFiles) { - this.$nextTick(() => { - this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) - }); - } - - this.connection = markRaw(os.stream.useChannel('drive')); - - this.connection.on('fileCreated', this.onStreamDriveFileCreated); - this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); - this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); - this.connection.on('folderCreated', this.onStreamDriveFolderCreated); - this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated); - this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted); - - if (this.initialFolder) { - this.move(this.initialFolder); - } else { - this.fetch(); - } - }, - - activated() { - if (this.$store.state.enableInfiniteScroll) { - this.$nextTick(() => { - this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el) - }); - } - }, - - beforeUnmount() { - this.connection.dispose(); - this.ilFilesObserver.disconnect(); - }, - - methods: { - onStreamDriveFileCreated(file) { - this.addFile(file, true); - }, - - onStreamDriveFileUpdated(file) { - const current = this.folder ? this.folder.id : null; - if (current != file.folderId) { - this.removeFile(file); - } else { - this.addFile(file, true); - } - }, - - onStreamDriveFileDeleted(fileId) { - this.removeFile(fileId); - }, - - onStreamDriveFolderCreated(folder) { - this.addFolder(folder, true); - }, - - onStreamDriveFolderUpdated(folder) { - const current = this.folder ? this.folder.id : null; - if (current != folder.parentId) { - this.removeFolder(folder); - } else { - this.addFolder(folder, true); - } - }, - - onStreamDriveFolderDeleted(folderId) { - this.removeFolder(folderId); - }, - - onDragover(e): any { - // ドラッグ元が自分自身の所有するアイテムだったら - if (this.isDragSource) { - // 自分自身にはドロップさせない - e.dataTransfer.dropEffect = 'none'; - return; - } - - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_; - - if (isFile || isDriveFile || isDriveFolder) { - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } else { - e.dataTransfer.dropEffect = 'none'; - } - - return false; - }, - - onDragenter(e) { - if (!this.isDragSource) this.draghover = true; - }, - - onDragleave(e) { - this.draghover = false; - }, - - onDrop(e): any { - this.draghover = false; - - // ドロップされてきたものがファイルだったら - if (e.dataTransfer.files.length > 0) { - for (const file of Array.from(e.dataTransfer.files)) { - this.upload(file, this.folder); - } - return; - } - - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - if (this.files.some(f => f.id == file.id)) return; - this.removeFile(file.id); - os.api('drive/files/update', { - fileId: file.id, - folderId: this.folder ? this.folder.id : null - }); - } - //#endregion - - //#region ドライブのフォルダ - const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder != '') { - const folder = JSON.parse(driveFolder); - - // 移動先が自分自身ならreject - if (this.folder && folder.id == this.folder.id) return false; - if (this.folders.some(f => f.id == folder.id)) return false; - this.removeFolder(folder.id); - os.api('drive/folders/update', { - folderId: folder.id, - parentId: this.folder ? this.folder.id : null - }).then(() => { - // noop - }).catch(err => { - switch (err) { - case 'detected-circular-definition': - os.dialog({ - title: this.$ts.unableToProcess, - text: this.$ts.circularReferenceFolder - }); - break; - default: - os.dialog({ - type: 'error', - text: this.$ts.somethingHappened - }); - } - }); - } - //#endregion - }, - - selectLocalFile() { - (this.$refs.fileInput as any).click(); - }, - - urlUpload() { - os.dialog({ - title: this.$ts.uploadFromUrl, - input: { - placeholder: this.$ts.uploadFromUrlDescription - } - }).then(({ canceled, result: url }) => { - if (canceled) return; - os.api('drive/files/upload-from-url', { - url: url, - folderId: this.folder ? this.folder.id : undefined - }); - - os.dialog({ - title: this.$ts.uploadFromUrlRequested, - text: this.$ts.uploadFromUrlMayTakeTime - }); - }); - }, - - createFolder() { - os.dialog({ - title: this.$ts.createFolder, - input: { - placeholder: this.$ts.folderName - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/folders/create', { - name: name, - parentId: this.folder ? this.folder.id : undefined - }).then(folder => { - this.addFolder(folder, true); - }); - }); - }, - - renameFolder(folder) { - os.dialog({ - title: this.$ts.renameFolder, - input: { - placeholder: this.$ts.inputNewFolderName, - default: folder.name - } - }).then(({ canceled, result: name }) => { - if (canceled) return; - os.api('drive/folders/update', { - folderId: folder.id, - name: name - }).then(folder => { - // FIXME: 画面を更新するために自分自身に移動 - this.move(folder); - }); - }); - }, - - deleteFolder(folder) { - os.api('drive/folders/delete', { - folderId: folder.id - }).then(() => { - // 削除時に親フォルダに移動 - this.move(folder.parentId); - }).catch(err => { - switch(err.id) { - case 'b0fc8a17-963c-405d-bfbc-859a487295e1': - os.dialog({ - type: 'error', - title: this.$ts.unableToDelete, - text: this.$ts.hasChildFilesOrFolders - }); - break; - default: - os.dialog({ - type: 'error', - text: this.$ts.unableToDelete - }); - } - }); - }, - - onChangeFileInput() { - for (const file of Array.from((this.$refs.fileInput as any).files)) { - this.upload(file, this.folder); - } - }, - - upload(file, folder) { - if (folder && typeof folder == 'object') folder = folder.id; - os.upload(file, folder).then(res => { - this.addFile(res, true); - }); - }, - - chooseFile(file) { - const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); - if (this.multiple) { - if (isAlreadySelected) { - this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); - } else { - this.selectedFiles.push(file); - } - this.$emit('change-selection', this.selectedFiles); - } else { - if (isAlreadySelected) { - this.$emit('selected', file); - } else { - this.selectedFiles = [file]; - this.$emit('change-selection', [file]); - } - } - }, - - chooseFolder(folder) { - const isAlreadySelected = this.selectedFolders.some(f => f.id == folder.id); - if (this.multiple) { - if (isAlreadySelected) { - this.selectedFolders = this.selectedFolders.filter(f => f.id != folder.id); - } else { - this.selectedFolders.push(folder); - } - this.$emit('change-selection', this.selectedFolders); - } else { - if (isAlreadySelected) { - this.$emit('selected', folder); - } else { - this.selectedFolders = [folder]; - this.$emit('change-selection', [folder]); - } - } - }, - - move(target) { - if (target == null) { - this.goRoot(); - return; - } else if (typeof target == 'object') { - target = target.id; - } - - this.fetching = true; - - os.api('drive/folders/show', { - folderId: target - }).then(folder => { - this.folder = folder; - this.hierarchyFolders = []; - - const dive = folder => { - this.hierarchyFolders.unshift(folder); - if (folder.parent) dive(folder.parent); - }; - - if (folder.parent) dive(folder.parent); - - this.$emit('open-folder', folder); - this.fetch(); - }); - }, - - addFolder(folder, unshift = false) { - const current = this.folder ? this.folder.id : null; - if (current != folder.parentId) return; - - if (this.folders.some(f => f.id == folder.id)) { - const exist = this.folders.map(f => f.id).indexOf(folder.id); - this.folders[exist] = folder; - return; - } - - if (unshift) { - this.folders.unshift(folder); - } else { - this.folders.push(folder); - } - }, - - addFile(file, unshift = false) { - const current = this.folder ? this.folder.id : null; - if (current != file.folderId) return; - - if (this.files.some(f => f.id == file.id)) { - const exist = this.files.map(f => f.id).indexOf(file.id); - this.files[exist] = file; - return; - } - - if (unshift) { - this.files.unshift(file); - } else { - this.files.push(file); - } - }, - - removeFolder(folder) { - if (typeof folder == 'object') folder = folder.id; - this.folders = this.folders.filter(f => f.id != folder); - }, - - removeFile(file) { - if (typeof file == 'object') file = file.id; - this.files = this.files.filter(f => f.id != file); - }, - - appendFile(file) { - this.addFile(file); - }, - - appendFolder(folder) { - this.addFolder(folder); - }, - - prependFile(file) { - this.addFile(file, true); - }, - - prependFolder(folder) { - this.addFolder(folder, true); - }, - - goRoot() { - // 既にrootにいるなら何もしない - if (this.folder == null) return; - - this.folder = null; - this.hierarchyFolders = []; - this.$emit('move-root'); - this.fetch(); - }, - - fetch() { - this.folders = []; - this.files = []; - this.moreFolders = false; - this.moreFiles = false; - this.fetching = true; - - let fetchedFolders = null; - let fetchedFiles = null; - - const foldersMax = 30; - const filesMax = 30; - - // フォルダ一覧取得 - os.api('drive/folders', { - folderId: this.folder ? this.folder.id : null, - limit: foldersMax + 1 - }).then(folders => { - if (folders.length == foldersMax + 1) { - this.moreFolders = true; - folders.pop(); - } - fetchedFolders = folders; - complete(); - }); - - // ファイル一覧取得 - os.api('drive/files', { - folderId: this.folder ? this.folder.id : null, - type: this.type, - limit: filesMax + 1 - }).then(files => { - if (files.length == filesMax + 1) { - this.moreFiles = true; - files.pop(); - } - fetchedFiles = files; - complete(); - }); - - let flag = false; - const complete = () => { - if (flag) { - for (const x of fetchedFolders) this.appendFolder(x); - for (const x of fetchedFiles) this.appendFile(x); - this.fetching = false; - } else { - flag = true; - } - }; - }, - - fetchMoreFiles() { - this.fetching = true; - - const max = 30; - - // ファイル一覧取得 - os.api('drive/files', { - folderId: this.folder ? this.folder.id : null, - type: this.type, - untilId: this.files[this.files.length - 1].id, - limit: max + 1 - }).then(files => { - if (files.length == max + 1) { - this.moreFiles = true; - files.pop(); - } else { - this.moreFiles = false; - } - for (const x of files) this.appendFile(x); - this.fetching = false; - }); - }, - - getMenu() { - return [{ - text: this.$ts.addFile, - type: 'label' - }, { - text: this.$ts.upload, - icon: 'fas fa-upload', - action: () => { this.selectLocalFile(); } - }, { - text: this.$ts.fromUrl, - icon: 'fas fa-link', - action: () => { this.urlUpload(); } - }, null, { - text: this.folder ? this.folder.name : this.$ts.drive, - type: 'label' - }, this.folder ? { - text: this.$ts.renameFolder, - icon: 'fas fa-i-cursor', - action: () => { this.renameFolder(this.folder); } - } : undefined, this.folder ? { - text: this.$ts.deleteFolder, - icon: 'fas fa-trash-alt', - action: () => { this.deleteFolder(this.folder); } - } : undefined, { - text: this.$ts.createFolder, - icon: 'fas fa-folder-plus', - action: () => { this.createFolder(); } - }]; - }, - - showMenu(ev) { - os.popupMenu(this.getMenu(), ev.currentTarget || ev.target); - }, - - onContextmenu(ev) { - os.contextMenu(this.getMenu(), ev); - }, - } -}); -</script> - -<style lang="scss" scoped> -.yfudmmck { - display: flex; - flex-direction: column; - height: 100%; - - > nav { - display: flex; - z-index: 2; - width: 100%; - padding: 0 8px; - box-sizing: border-box; - overflow: auto; - font-size: 0.9em; - box-shadow: 0 1px 0 var(--divider); - - &, * { - user-select: none; - } - - > .path { - display: inline-block; - vertical-align: bottom; - line-height: 38px; - white-space: nowrap; - - > * { - display: inline-block; - margin: 0; - padding: 0 8px; - line-height: 38px; - cursor: pointer; - - * { - pointer-events: none; - } - - &:hover { - text-decoration: underline; - } - - &.current { - font-weight: bold; - cursor: default; - - &:hover { - text-decoration: none; - } - } - - &.separator { - margin: 0; - padding: 0; - opacity: 0.5; - cursor: default; - - > i { - margin: 0; - } - } - } - } - - > .menu { - margin-left: auto; - } - } - - > .main { - flex: 1; - overflow: auto; - padding: var(--margin); - - &, * { - user-select: none; - } - - &.fetching { - cursor: wait !important; - - * { - pointer-events: none; - } - - > .contents { - opacity: 0.5; - } - } - - &.uploading { - height: calc(100% - 38px - 100px); - } - - > .contents { - - > .folders, - > .files { - display: flex; - flex-wrap: wrap; - - > .folder, - > .file { - flex-grow: 1; - width: 128px; - margin: 4px; - box-sizing: border-box; - } - - > .padding { - flex-grow: 1; - pointer-events: none; - width: 128px + 8px; - } - } - - > .empty { - padding: 16px; - text-align: center; - pointer-events: none; - opacity: 0.5; - - > p { - margin: 0; - } - } - } - } - - > .dropzone { - position: absolute; - left: 0; - top: 38px; - width: 100%; - height: calc(100% - 38px); - border: dashed 2px var(--focus); - pointer-events: none; - } - - > input { - display: none; - } -} -</style> diff --git a/src/client/components/emoji-picker-dialog.vue b/src/client/components/emoji-picker-dialog.vue deleted file mode 100644 index aa17b8b250..0000000000 --- a/src/client/components/emoji-picker-dialog.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<MkPopup ref="popup" :manual-showing="manualShowing" :src="src" :front="true" @click="$refs.popup.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')" #default="{point}"> - <MkEmojiPicker class="ryghynhb _popup _shadow" :class="{ pointer: point === 'top' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen" ref="picker"/> -</MkPopup> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import MkPopup from '@client/components/ui/popup.vue'; -import MkEmojiPicker from '@client/components/emoji-picker.vue'; - -export default defineComponent({ - components: { - MkPopup, - MkEmojiPicker, - }, - - props: { - manualShowing: { - type: Boolean, - required: false, - default: null, - }, - src: { - required: false - }, - showPinned: { - required: false, - default: true - }, - asReactionPicker: { - required: false - }, - }, - - emits: ['done', 'close', 'closed'], - - data() { - return { - - }; - }, - - methods: { - chosen(emoji: any) { - this.$emit('done', emoji); - this.$refs.popup.close(); - }, - - opening() { - this.$refs.picker.reset(); - this.$refs.picker.focus(); - } - } -}); -</script> - -<style lang="scss" scoped> -.ryghynhb { - &.pointer { - &:before { - --size: 8px; - content: ''; - display: block; - position: absolute; - top: calc(0px - (var(--size) * 2)); - left: 0; - right: 0; - width: 0; - margin: auto; - border: solid var(--size) transparent; - border-bottom-color: var(--popup); - } - } -} -</style> diff --git a/src/client/components/emoji-picker-window.vue b/src/client/components/emoji-picker-window.vue deleted file mode 100644 index b7b884565b..0000000000 --- a/src/client/components/emoji-picker-window.vue +++ /dev/null @@ -1,197 +0,0 @@ -<template> -<MkWindow ref="window" - :initial-width="null" - :initial-height="null" - :can-resize="false" - :mini="true" - :front="true" - @closed="$emit('closed')" -> - <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/> -</MkWindow> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import MkWindow from '@client/components/ui/window.vue'; -import MkEmojiPicker from '@client/components/emoji-picker.vue'; - -export default defineComponent({ - components: { - MkWindow, - MkEmojiPicker, - }, - - props: { - src: { - required: false - }, - showPinned: { - required: false, - default: true - }, - asReactionPicker: { - required: false - }, - }, - - emits: ['chosen', 'closed'], - - data() { - return { - - }; - }, - - methods: { - chosen(emoji: any) { - this.$emit('chosen', emoji); - }, - } -}); -</script> - -<style lang="scss" scoped> -.omfetrab { - $pad: 8px; - --eachSize: 40px; - - display: flex; - flex-direction: column; - contain: content; - - &.big { - --eachSize: 44px; - } - - &.w1 { - width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); - } - - &.w2 { - width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); - } - - &.w3 { - width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); - } - - &.h1 { - --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); - } - - &.h2 { - --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); - } - - &.h3 { - --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); - } - - > .search { - width: 100%; - padding: 12px; - box-sizing: border-box; - font-size: 1em; - outline: none; - border: none; - background: transparent; - color: var(--fg); - - &:not(.filled) { - order: 1; - z-index: 2; - box-shadow: 0px -1px 0 0px var(--divider); - } - } - - > .emojis { - height: var(--height); - overflow-y: auto; - overflow-x: hidden; - - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - - > .index { - min-height: var(--height); - position: relative; - border-bottom: solid 0.5px var(--divider); - - > .arrow { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - padding: 16px 0; - text-align: center; - opacity: 0.5; - pointer-events: none; - } - } - - section { - > header { - position: sticky; - top: 0; - left: 0; - z-index: 1; - padding: 8px; - font-size: 12px; - } - - > div { - padding: $pad; - - > button { - position: relative; - padding: 0; - width: var(--eachSize); - height: var(--eachSize); - border-radius: 4px; - - &:focus-visible { - outline: solid 2px var(--focus); - z-index: 1; - } - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &:active { - background: var(--accent); - box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); - } - - > * { - font-size: 24px; - height: 1.25em; - vertical-align: -.25em; - pointer-events: none; - } - } - } - - &.result { - border-bottom: solid 0.5px var(--divider); - - &:empty { - display: none; - } - } - - &.unicode { - min-height: 384px; - } - - &.custom { - min-height: 64px; - } - } - } -} -</style> diff --git a/src/client/components/emoji-picker.section.vue b/src/client/components/emoji-picker.section.vue deleted file mode 100644 index 0ea3761429..0000000000 --- a/src/client/components/emoji-picker.section.vue +++ /dev/null @@ -1,50 +0,0 @@ -<template> -<section> - <header class="_acrylic" @click="shown = !shown"> - <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }}) - </header> - <div v-if="shown"> - <button v-for="emoji in emojis" - class="_button" - @click="chosen(emoji, $event)" - :key="emoji" - > - <MkEmoji :emoji="emoji" :normal="true"/> - </button> - </div> -</section> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; - -export default defineComponent({ - props: { - emojis: { - required: true, - }, - initialShown: { - required: false - } - }, - - emits: ['chosen'], - - data() { - return { - getStaticImageUrl, - shown: this.initialShown, - }; - }, - - methods: { - chosen(emoji: any, ev) { - this.$parent.chosen(emoji, ev); - }, - } -}); -</script> - -<style lang="scss" scoped> -</style> diff --git a/src/client/components/emoji-picker.vue b/src/client/components/emoji-picker.vue deleted file mode 100644 index 85a12a08e6..0000000000 --- a/src/client/components/emoji-picker.vue +++ /dev/null @@ -1,501 +0,0 @@ -<template> -<div class="omfetrab" :class="['w' + width, 'h' + height, { big }]"> - <input ref="search" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" v-model.trim="q" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()"> - <div class="emojis" ref="emojis"> - <section class="result"> - <div v-if="searchResultCustom.length > 0"> - <button v-for="emoji in searchResultCustom" - class="_button" - :title="emoji.name" - @click="chosen(emoji, $event)" - :key="emoji" - tabindex="0" - > - <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/> - <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> - </button> - </div> - <div v-if="searchResultUnicode.length > 0"> - <button v-for="emoji in searchResultUnicode" - class="_button" - :title="emoji.name" - @click="chosen(emoji, $event)" - :key="emoji.name" - tabindex="0" - > - <MkEmoji :emoji="emoji.char"/> - </button> - </div> - </section> - - <div class="index" v-if="tab === 'index'"> - <section v-if="showPinned"> - <div> - <button v-for="emoji in pinned" - class="_button" - @click="chosen(emoji, $event)" - tabindex="0" - :key="emoji" - > - <MkEmoji :emoji="emoji" :normal="true"/> - </button> - </div> - </section> - - <section> - <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ $ts.recentUsed }}</header> - <div> - <button v-for="emoji in $store.state.recentlyUsedEmojis" - class="_button" - @click="chosen(emoji, $event)" - :key="emoji" - > - <MkEmoji :emoji="emoji" :normal="true"/> - </button> - </div> - </section> - </div> - <div> - <header class="_acrylic">{{ $ts.customEmojis }}</header> - <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')">{{ category || $ts.other }}</XSection> - </div> - <div> - <header class="_acrylic">{{ $ts.emoji }}</header> - <XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)">{{ category }}</XSection> - </div> - </div> - <div class="tabs"> - <button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="fas fa-asterisk fa-fw"></i></button> - <button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="fas fa-laugh fa-fw"></i></button> - <button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="fas fa-leaf fa-fw"></i></button> - <button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="fas fa-hashtag fa-fw"></i></button> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import { emojilist } from '@/misc/emojilist'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import Particle from '@client/components/particle.vue'; -import * as os from '@client/os'; -import { isDeviceTouch } from '@client/scripts/is-device-touch'; -import { isMobile } from '@client/scripts/is-mobile'; -import { emojiCategories } from '@client/instance'; -import XSection from './emoji-picker.section.vue'; - -export default defineComponent({ - components: { - XSection - }, - - props: { - showPinned: { - required: false, - default: true - }, - asReactionPicker: { - required: false - }, - }, - - emits: ['chosen'], - - data() { - return { - emojilist: markRaw(emojilist), - getStaticImageUrl, - pinned: this.$store.reactiveState.reactions, - width: this.asReactionPicker ? this.$store.state.reactionPickerWidth : 3, - height: this.asReactionPicker ? this.$store.state.reactionPickerHeight : 2, - big: this.asReactionPicker ? isDeviceTouch : false, - customEmojiCategories: emojiCategories, - customEmojis: this.$instance.emojis, - q: null, - searchResultCustom: [], - searchResultUnicode: [], - tab: 'index', - categories: ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'], - }; - }, - - watch: { - q() { - this.$refs.emojis.scrollTop = 0; - - if (this.q == null || this.q === '') { - this.searchResultCustom = []; - this.searchResultUnicode = []; - return; - } - - const q = this.q.replace(/:/g, ''); - - const searchCustom = () => { - const max = 8; - const emojis = this.customEmojis; - const matches = new Set(); - - const exactMatch = emojis.find(e => e.name === q); - if (exactMatch) matches.add(exactMatch); - - if (q.includes(' ')) { // AND検索 - const keywords = q.split(' '); - - // 名前にキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - // 名前またはエイリアスにキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - } else { - for (const emoji of emojis) { - if (emoji.name.startsWith(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - for (const emoji of emojis) { - if (emoji.aliases.some(alias => alias.startsWith(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - for (const emoji of emojis) { - if (emoji.name.includes(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - for (const emoji of emojis) { - if (emoji.aliases.some(alias => alias.includes(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - } - - return matches; - }; - - const searchUnicode = () => { - const max = 8; - const emojis = this.emojilist; - const matches = new Set(); - - const exactMatch = emojis.find(e => e.name === q); - if (exactMatch) matches.add(exactMatch); - - if (q.includes(' ')) { // AND検索 - const keywords = q.split(' '); - - // 名前にキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - // 名前またはエイリアスにキーワードが含まれている - for (const emoji of emojis) { - if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - } else { - for (const emoji of emojis) { - if (emoji.name.startsWith(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - for (const emoji of emojis) { - if (emoji.keywords.some(keyword => keyword.startsWith(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - for (const emoji of emojis) { - if (emoji.name.includes(q)) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - if (matches.size >= max) return matches; - - for (const emoji of emojis) { - if (emoji.keywords.some(keyword => keyword.includes(q))) { - matches.add(emoji); - if (matches.size >= max) break; - } - } - } - - return matches; - }; - - this.searchResultCustom = Array.from(searchCustom()); - this.searchResultUnicode = Array.from(searchUnicode()); - } - }, - - mounted() { - this.focus(); - }, - - methods: { - focus() { - if (!isMobile && !isDeviceTouch) { - this.$refs.search.focus({ - preventScroll: true - }); - } - }, - - reset() { - this.$refs.emojis.scrollTop = 0; - this.q = ''; - }, - - getKey(emoji: any) { - return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`); - }, - - chosen(emoji: any, ev) { - if (ev) { - const el = ev.currentTarget || ev.target; - const rect = el.getBoundingClientRect(); - const x = rect.left + (el.clientWidth / 2); - const y = rect.top + (el.clientHeight / 2); - os.popup(Particle, { x, y }, {}, 'end'); - } - - const key = this.getKey(emoji); - this.$emit('chosen', key); - - // 最近使った絵文字更新 - if (!this.pinned.includes(key)) { - let recents = this.$store.state.recentlyUsedEmojis; - recents = recents.filter((e: any) => e !== key); - recents.unshift(key); - this.$store.set('recentlyUsedEmojis', recents.splice(0, 32)); - } - }, - - paste(event) { - const paste = (event.clipboardData || window.clipboardData).getData('text'); - if (this.done(paste)) { - event.preventDefault(); - } - }, - - done(query) { - if (query == null) query = this.q; - if (query == null) return; - const q = query.replace(/:/g, ''); - const exactMatchCustom = this.customEmojis.find(e => e.name === q); - if (exactMatchCustom) { - this.chosen(exactMatchCustom); - return true; - } - const exactMatchUnicode = this.emojilist.find(e => e.char === q || e.name === q); - if (exactMatchUnicode) { - this.chosen(exactMatchUnicode); - return true; - } - if (this.searchResultCustom.length > 0) { - this.chosen(this.searchResultCustom[0]); - return true; - } - if (this.searchResultUnicode.length > 0) { - this.chosen(this.searchResultUnicode[0]); - return true; - } - }, - } -}); -</script> - -<style lang="scss" scoped> -.omfetrab { - $pad: 8px; - --eachSize: 40px; - - display: flex; - flex-direction: column; - - &.big { - --eachSize: 44px; - } - - &.w1 { - width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); - } - - &.w2 { - width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); - } - - &.w3 { - width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); - } - - &.h1 { - --height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); - } - - &.h2 { - --height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); - } - - &.h3 { - --height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); - } - - > .search { - width: 100%; - padding: 12px; - box-sizing: border-box; - font-size: 1em; - outline: none; - border: none; - background: transparent; - color: var(--fg); - - &:not(.filled) { - order: 1; - z-index: 2; - box-shadow: 0px -1px 0 0px var(--divider); - } - } - - > .tabs { - display: flex; - display: none; - - > .tab { - flex: 1; - height: 38px; - border-top: solid 0.5px var(--divider); - - &.active { - border-top: solid 1px var(--accent); - color: var(--accent); - } - } - } - - > .emojis { - height: var(--height); - overflow-y: auto; - overflow-x: hidden; - - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - - > div { - &:not(.index) { - padding: 4px 0 8px 0; - border-top: solid 0.5px var(--divider); - } - - > header { - /*position: sticky; - top: 0; - left: 0;*/ - height: 32px; - line-height: 32px; - z-index: 2; - padding: 0 8px; - font-size: 12px; - } - } - - ::v-deep(section) { - > header { - position: sticky; - top: 0; - left: 0; - height: 32px; - line-height: 32px; - z-index: 1; - padding: 0 8px; - font-size: 12px; - cursor: pointer; - - &:hover { - color: var(--accent); - } - } - - > div { - position: relative; - padding: $pad; - - > button { - position: relative; - padding: 0; - width: var(--eachSize); - height: var(--eachSize); - border-radius: 4px; - - &:focus-visible { - outline: solid 2px var(--focus); - z-index: 1; - } - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &:active { - background: var(--accent); - box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); - } - - > * { - font-size: 24px; - height: 1.25em; - vertical-align: -.25em; - pointer-events: none; - } - } - } - - &.result { - border-bottom: solid 0.5px var(--divider); - - &:empty { - display: none; - } - } - } - } -} -</style> diff --git a/src/client/components/featured-photos.vue b/src/client/components/featured-photos.vue deleted file mode 100644 index c992a5d1fb..0000000000 --- a/src/client/components/featured-photos.vue +++ /dev/null @@ -1,32 +0,0 @@ -<template> -<div class="xfbouadm" v-if="meta" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - }, - - data() { - return { - meta: null, - }; - }, - - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, -}); -</script> - -<style lang="scss" scoped> -.xfbouadm { - background-position: center; - background-size: cover; -} -</style> diff --git a/src/client/components/file-type-icon.vue b/src/client/components/file-type-icon.vue deleted file mode 100644 index 95200b98c2..0000000000 --- a/src/client/components/file-type-icon.vue +++ /dev/null @@ -1,28 +0,0 @@ -<template> -<span class="mk-file-type-icon"> - <template v-if="kind == 'image'"><i class="fas fa-file-image"></i></template> -</span> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - type: { - type: String, - required: true, - } - }, - data() { - return { - }; - }, - computed: { - kind(): string { - return this.type.split('/')[0]; - } - } -}); -</script> diff --git a/src/client/components/follow-button.vue b/src/client/components/follow-button.vue deleted file mode 100644 index 5eba9b1f6b..0000000000 --- a/src/client/components/follow-button.vue +++ /dev/null @@ -1,210 +0,0 @@ -<template> -<button class="kpoogebi _button" - :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }" - @click="onClick" - :disabled="wait" -> - <template v-if="!wait"> - <template v-if="hasPendingFollowRequestFromYou && user.isLocked"> - <span v-if="full">{{ $ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i> - </template> - <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 --> - <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i> - </template> - <template v-else-if="isFollowing"> - <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i> - </template> - <template v-else-if="!isFollowing && user.isLocked"> - <span v-if="full">{{ $ts.followRequest }}</span><i class="fas fa-plus"></i> - </template> - <template v-else-if="!isFollowing && !user.isLocked"> - <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i> - </template> - </template> - <template v-else> - <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i> - </template> -</button> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - user: { - type: Object, - required: true - }, - full: { - type: Boolean, - required: false, - default: false, - }, - large: { - type: Boolean, - required: false, - default: false, - }, - }, - - data() { - return { - isFollowing: this.user.isFollowing, - hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou, - wait: false, - connection: null, - }; - }, - - created() { - // 渡されたユーザー情報が不完全な場合 - if (this.user.isFollowing == null) { - os.api('users/show', { - userId: this.user.id - }).then(u => { - this.isFollowing = u.isFollowing; - this.hasPendingFollowRequestFromYou = u.hasPendingFollowRequestFromYou; - }); - } - }, - - mounted() { - this.connection = markRaw(os.stream.useChannel('main')); - - this.connection.on('follow', this.onFollowChange); - this.connection.on('unfollow', this.onFollowChange); - }, - - beforeUnmount() { - this.connection.dispose(); - }, - - methods: { - onFollowChange(user) { - if (user.id == this.user.id) { - this.isFollowing = user.isFollowing; - this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; - } - }, - - async onClick() { - this.wait = true; - - try { - if (this.isFollowing) { - const { canceled } = await os.dialog({ - type: 'warning', - text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }), - showCancelButton: true - }); - - if (canceled) return; - - await os.api('following/delete', { - userId: this.user.id - }); - } else { - if (this.hasPendingFollowRequestFromYou) { - await os.api('following/requests/cancel', { - userId: this.user.id - }); - } else if (this.user.isLocked) { - await os.api('following/create', { - userId: this.user.id - }); - this.hasPendingFollowRequestFromYou = true; - } else { - await os.api('following/create', { - userId: this.user.id - }); - this.hasPendingFollowRequestFromYou = true; - } - } - } catch (e) { - console.error(e); - } finally { - this.wait = false; - } - } - } -}); -</script> - -<style lang="scss" scoped> -.kpoogebi { - position: relative; - display: inline-block; - font-weight: bold; - color: var(--accent); - background: transparent; - border: solid 1px var(--accent); - padding: 0; - height: 31px; - font-size: 16px; - border-radius: 32px; - background: #fff; - - &.full { - padding: 0 8px 0 12px; - font-size: 14px; - } - - &.large { - font-size: 16px; - height: 38px; - padding: 0 12px 0 16px; - } - - &:not(.full) { - width: 31px; - } - - &:focus-visible { - &:after { - content: ""; - pointer-events: none; - position: absolute; - top: -5px; - right: -5px; - bottom: -5px; - left: -5px; - border: 2px solid var(--focus); - border-radius: 32px; - } - } - - &:hover { - //background: mix($primary, #fff, 20); - } - - &:active { - //background: mix($primary, #fff, 40); - } - - &.active { - color: #fff; - background: var(--accent); - - &:hover { - background: var(--accentLighten); - border-color: var(--accentLighten); - } - - &:active { - background: var(--accentDarken); - border-color: var(--accentDarken); - } - } - - &.wait { - cursor: wait !important; - opacity: 0.7; - } - - > span { - margin-right: 6px; - } -} -</style> diff --git a/src/client/components/forgot-password.vue b/src/client/components/forgot-password.vue deleted file mode 100644 index 7fcf9aa720..0000000000 --- a/src/client/components/forgot-password.vue +++ /dev/null @@ -1,84 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="370" - :height="400" - @close="$refs.dialog.close()" - @closed="$emit('closed')" -> - <template #header>{{ $ts.forgotPassword }}</template> - - <form class="bafeceda" @submit.prevent="onSubmit" v-if="$instance.enableEmail"> - <div class="main _formRoot"> - <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required> - <template #label>{{ $ts.username }}</template> - <template #prefix>@</template> - </MkInput> - - <MkInput class="_formBlock" v-model="email" type="email" spellcheck="false" required> - <template #label>{{ $ts.emailAddress }}</template> - <template #caption>{{ $ts._forgotPassword.enterEmail }}</template> - </MkInput> - - <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton> - </div> - <div class="sub"> - <MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA> - </div> - </form> - <div v-else> - {{ $ts._forgotPassword.contactAdmin }} - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XModalWindow, - MkButton, - MkInput, - }, - - emits: ['done', 'closed'], - - data() { - return { - username: '', - email: '', - processing: false, - }; - }, - - methods: { - async onSubmit() { - this.processing = true; - await os.apiWithDialog('request-reset-password', { - username: this.username, - email: this.email, - }); - - this.$emit('done'); - this.$refs.dialog.close(); - } - } -}); -</script> - -<style lang="scss" scoped> -.bafeceda { - > .main { - padding: 24px; - } - - > .sub { - border-top: solid 0.5px var(--divider); - padding: 24px; - } -} -</style> diff --git a/src/client/components/form-dialog.vue b/src/client/components/form-dialog.vue deleted file mode 100644 index 6353b7287e..0000000000 --- a/src/client/components/form-dialog.vue +++ /dev/null @@ -1,125 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="450" - :can-close="false" - :with-ok-button="true" - :ok-button-disabled="false" - @click="cancel()" - @ok="ok()" - @close="cancel()" - @closed="$emit('closed')" -> - <template #header> - {{ title }} - </template> - <FormBase class="xkpnjxcv"> - <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> - <FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1"> - <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> - <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </FormInput> - <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text"> - <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> - <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </FormInput> - <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]"> - <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span> - <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </FormTextarea> - <FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]"> - <span v-text="form[item].label || item"></span> - <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </FormSwitch> - <FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> - <option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option> - </FormSelect> - <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]"> - <template #desc><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> - <option v-for="item in form[item].options" :value="item.value" :key="item.value">{{ item.label }}</option> - </FormRadios> - <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step"> - <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> - <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </FormRange> - <FormButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> - <span v-text="form[item].content || item"></span> - </FormButton> - </template> - </FormBase> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import FormBase from './debobigego/base.vue'; -import FormInput from './debobigego/input.vue'; -import FormTextarea from './debobigego/textarea.vue'; -import FormSwitch from './debobigego/switch.vue'; -import FormSelect from './debobigego/select.vue'; -import FormRange from './debobigego/range.vue'; -import FormButton from './debobigego/button.vue'; -import FormRadios from './debobigego/radios.vue'; - -export default defineComponent({ - components: { - XModalWindow, - FormBase, - FormInput, - FormTextarea, - FormSwitch, - FormSelect, - FormRange, - FormButton, - FormRadios, - }, - - props: { - title: { - type: String, - required: true, - }, - form: { - type: Object, - required: true, - }, - }, - - emits: ['done'], - - data() { - return { - values: {} - }; - }, - - created() { - for (const item in this.form) { - this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null; - } - }, - - methods: { - ok() { - this.$emit('done', { - result: this.values - }); - this.$refs.dialog.close(); - }, - - cancel() { - this.$emit('done', { - canceled: true - }); - this.$refs.dialog.close(); - } - } -}); -</script> - -<style lang="scss" scoped> -.xkpnjxcv { - -} -</style> diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue deleted file mode 100644 index 591eda9ed5..0000000000 --- a/src/client/components/form/input.vue +++ /dev/null @@ -1,315 +0,0 @@ -<template> -<div class="matxzzsk"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ inline, disabled, focused }"> - <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <input ref="inputEl" - :type="type" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - :step="step" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - :list="id" - > - <datalist :id="id" v-if="datalist"> - <option v-for="data in datalist" :value="data"/> - </datalist> - <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> - </div> - <div class="caption"><slot name="caption"></slot></div> - - <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import { debounce } from 'throttle-debounce'; - -export default defineComponent({ - components: { - MkButton, - }, - - props: { - modelValue: { - required: true - }, - type: { - type: String, - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - autocomplete: { - required: false - }, - spellcheck: { - required: false - }, - step: { - required: false - }, - datalist: { - type: Array, - required: false, - }, - inline: { - type: Boolean, - required: false, - default: false - }, - debounce: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['change', 'keydown', 'enter', 'update:modelValue'], - - setup(props, context) { - const { modelValue, type, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const id = Math.random().toString(); // TODO: uuid? - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - const prefixEl = ref(null); - const suffixEl = ref(null); - - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - const onKeydown = (ev: KeyboardEvent) => { - context.emit('keydown', ev); - - if (ev.code === 'Enter') { - context.emit('enter'); - } - }; - - const updated = () => { - changed.value = false; - if (type?.value === 'number') { - context.emit('update:modelValue', parseFloat(v.value)); - } else { - context.emit('update:modelValue', v.value); - } - }; - - const debouncedUpdated = debounce(1000, updated); - - watch(modelValue, newValue => { - v.value = newValue; - }); - - watch(v, newValue => { - if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } - } - - invalid.value = inputEl.value.validity.badInput; - }); - - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } - - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); - - onUnmounted(() => { - clearInterval(clock); - }); - }); - }); - - return { - id, - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - focus, - onInput, - onKeydown, - updated, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.matxzzsk { - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; - - &:empty { - display: none; - } - } - - > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; - color: var(--fgTransparentWeak); - - &:empty { - display: none; - } - } - - > .input { - $height: 42px; - position: relative; - - > input { - appearance: none; - -webkit-appearance: none; - display: block; - height: $height; - width: 100%; - margin: 0; - padding: 0 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 0.5px var(--inputBorder); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - transition: border-color 0.1s ease-out; - - &:hover { - border-color: var(--inputBorderHover); - } - } - - > .prefix, - > .suffix { - display: flex; - align-items: center; - position: absolute; - z-index: 1; - top: 0; - padding: 0 12px; - font-size: 1em; - height: $height; - pointer-events: none; - - &:empty { - display: none; - } - - > * { - display: inline-block; - min-width: 16px; - max-width: 150px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - > .prefix { - left: 0; - padding-right: 6px; - } - - > .suffix { - right: 0; - padding-left: 6px; - } - - &.inline { - display: inline-block; - margin: 0; - } - - &.focused { - > input { - border-color: var(--accent); - //box-shadow: 0 0 0 4px var(--focus); - } - } - - &.disabled { - opacity: 0.7; - - &, * { - cursor: not-allowed !important; - } - } - } -} -</style> diff --git a/src/client/components/form/radio.vue b/src/client/components/form/radio.vue deleted file mode 100644 index 0f31d8fa0a..0000000000 --- a/src/client/components/form/radio.vue +++ /dev/null @@ -1,122 +0,0 @@ -<template> -<div - class="novjtctn" - :class="{ disabled, checked }" - :aria-checked="checked" - :aria-disabled="disabled" - @click="toggle" -> - <input type="radio" - :disabled="disabled" - > - <span class="button"> - <span></span> - </span> - <span class="label"><slot></slot></span> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - modelValue: { - required: false - }, - value: { - required: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.modelValue === this.value; - } - }, - methods: { - toggle() { - if (this.disabled) return; - this.$emit('update:modelValue', this.value); - } - } -}); -</script> - -<style lang="scss" scoped> -.novjtctn { - position: relative; - display: inline-block; - margin: 8px 20px 0 0; - text-align: left; - cursor: pointer; - transition: all 0.3s; - - > * { - user-select: none; - } - - &.disabled { - opacity: 0.6; - - &, * { - cursor: not-allowed !important; - } - } - - &.checked { - > .button { - border-color: var(--accent); - - &:after { - background-color: var(--accent); - transform: scale(1); - opacity: 1; - } - } - } - - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; - } - - > .button { - position: absolute; - width: 20px; - height: 20px; - background: none; - border: solid 2px var(--inputBorder); - border-radius: 100%; - transition: inherit; - - &:after { - content: ''; - display: block; - position: absolute; - top: 3px; - right: 3px; - bottom: 3px; - left: 3px; - border-radius: 100%; - opacity: 0; - transform: scale(0); - transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); - } - } - - > .label { - margin-left: 28px; - display: block; - font-size: 16px; - line-height: 20px; - cursor: pointer; - } -} -</style> diff --git a/src/client/components/form/radios.vue b/src/client/components/form/radios.vue deleted file mode 100644 index 998a738202..0000000000 --- a/src/client/components/form/radios.vue +++ /dev/null @@ -1,54 +0,0 @@ -<script lang="ts"> -import { defineComponent, h } from 'vue'; -import MkRadio from './radio.vue'; - -export default defineComponent({ - components: { - MkRadio - }, - props: { - modelValue: { - required: false - }, - }, - data() { - return { - value: this.modelValue, - } - }, - watch: { - value() { - this.$emit('update:modelValue', this.value); - } - }, - render() { - let options = this.$slots.default(); - - // なぜかFragmentになることがあるため - if (options.length === 1 && options[0].props == null) options = options[0].children; - - return h('div', { - class: 'novjtcto' - }, [ - ...options.map(option => h(MkRadio, { - key: option.key, - value: option.props.value, - modelValue: this.value, - 'onUpdate:modelValue': value => this.value = value, - }, option.children)) - ]); - } -}); -</script> - -<style lang="scss"> -.novjtcto { - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } -} -</style> diff --git a/src/client/components/form/range.vue b/src/client/components/form/range.vue deleted file mode 100644 index 4cfe66a8fc..0000000000 --- a/src/client/components/form/range.vue +++ /dev/null @@ -1,139 +0,0 @@ -<template> -<div class="timctyfi" :class="{ focused, disabled }"> - <div class="icon"><slot name="icon"></slot></div> - <span class="label"><slot name="label"></slot></span> - <input - type="range" - ref="input" - v-model="v" - :disabled="disabled" - :min="min" - :max="max" - :step="step" - :autofocus="autofocus" - @focus="focused = true" - @blur="focused = false" - @input="$emit('update:value', $event.target.value)" - /> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - value: { - type: Number, - required: false, - default: 0 - }, - disabled: { - type: Boolean, - required: false, - default: false - }, - min: { - type: Number, - required: false, - default: 0 - }, - max: { - type: Number, - required: false, - default: 100 - }, - step: { - type: Number, - required: false, - default: 1 - }, - autofocus: { - type: Boolean, - required: false - } - }, - data() { - return { - v: this.value, - focused: false - }; - }, - watch: { - value(v) { - this.v = parseFloat(v); - } - }, - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$refs.input.focus(); - }); - } - } -}); -</script> - -<style lang="scss" scoped> -.timctyfi { - position: relative; - margin: 8px; - - > .icon { - display: inline-block; - width: 24px; - text-align: center; - } - - > .title { - pointer-events: none; - font-size: 16px; - color: var(--inputLabel); - overflow: hidden; - } - - > input { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - background: var(--X10); - height: 7px; - margin: 0 8px; - outline: 0; - border: 0; - border-radius: 7px; - - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - box-sizing: content-box; - } - - &::-moz-range-thumb { - -moz-appearance: none; - appearance: none; - cursor: pointer; - width: 20px; - height: 20px; - display: block; - border-radius: 50%; - border: none; - background: var(--accent); - box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); - } - } -} -</style> diff --git a/src/client/components/form/section.vue b/src/client/components/form/section.vue deleted file mode 100644 index 8eac40a0db..0000000000 --- a/src/client/components/form/section.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> -<div class="vrtktovh" v-size="{ max: [500] }" v-sticky-container> - <div class="label"><slot name="label"></slot></div> - <div class="main"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - -}); -</script> - -<style lang="scss" scoped> -.vrtktovh { - border-top: solid 0.5px var(--divider); - - > .label { - font-weight: bold; - padding: 24px 0 16px 0; - } - - > .main { - margin-bottom: 32px; - } -} -</style> diff --git a/src/client/components/form/select.vue b/src/client/components/form/select.vue deleted file mode 100644 index 363b3515fa..0000000000 --- a/src/client/components/form/select.vue +++ /dev/null @@ -1,312 +0,0 @@ -<template> -<div class="vblkjoeq"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick" ref="container"> - <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> - <select class="select" ref="inputEl" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - @focus="focused = true" - @blur="focused = false" - @input="onInput" - > - <slot></slot> - </select> - <div class="suffix" ref="suffixEl"><i class="fas fa-chevron-down"></i></div> - </div> - <div class="caption"><slot name="caption"></slot></div> - - <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkButton, - }, - - props: { - modelValue: { - required: true - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['change', 'update:modelValue'], - - setup(props, context) { - const { modelValue, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - const prefixEl = ref(null); - const suffixEl = ref(null); - const container = ref(null); - - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; - - watch(modelValue, newValue => { - v.value = newValue; - }); - - watch(v, newValue => { - if (!props.manualSave) { - updated(); - } - - invalid.value = inputEl.value.validity.badInput; - }); - - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } - - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); - - onUnmounted(() => { - clearInterval(clock); - }); - }); - }); - - const onClick = (ev: MouseEvent) => { - focused.value = true; - - const menu = []; - let options = context.slots.default(); - - const pushOption = (option: VNode) => { - menu.push({ - text: option.children, - active: v.value === option.props.value, - action: () => { - v.value = option.props.value; - }, - }); - }; - - const scanOptions = (options: VNode[]) => { - for (const vnode of options) { - if (vnode.type === 'optgroup') { - const optgroup = vnode; - menu.push({ - type: 'label', - text: optgroup.props.label, - }); - scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - scanOptions(fragment.children); - } else { - const option = vnode; - pushOption(option); - } - } - }; - - scanOptions(options); - - os.popupMenu(menu, container.value, { - width: container.value.offsetWidth, - }).then(() => { - focused.value = false; - }); - }; - - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - container, - focus, - onInput, - onClick, - updated, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.vblkjoeq { - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; - - &:empty { - display: none; - } - } - - > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; - color: var(--fgTransparentWeak); - - &:empty { - display: none; - } - } - - > .input { - $height: 42px; - position: relative; - cursor: pointer; - - &:hover { - > .select { - border-color: var(--inputBorderHover); - } - } - - > .select { - appearance: none; - -webkit-appearance: none; - display: block; - height: $height; - width: 100%; - margin: 0; - padding: 0 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--inputBorder); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - cursor: pointer; - transition: border-color 0.1s ease-out; - pointer-events: none; - } - - > .prefix, - > .suffix { - display: flex; - align-items: center; - position: absolute; - z-index: 1; - top: 0; - padding: 0 12px; - font-size: 1em; - height: $height; - pointer-events: none; - - &:empty { - display: none; - } - - > * { - display: inline-block; - min-width: 16px; - max-width: 150px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - > .prefix { - left: 0; - padding-right: 6px; - } - - > .suffix { - right: 0; - padding-left: 6px; - } - - &.inline { - display: inline-block; - margin: 0; - } - - &.focused { - > select { - border-color: var(--accent); - } - } - - &.disabled { - opacity: 0.7; - - &, * { - cursor: not-allowed !important; - } - } - } -} -</style> diff --git a/src/client/components/form/slot.vue b/src/client/components/form/slot.vue deleted file mode 100644 index 8580c1307d..0000000000 --- a/src/client/components/form/slot.vue +++ /dev/null @@ -1,50 +0,0 @@ -<template> -<div class="adhpbeou"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="content"> - <slot></slot> - </div> - <div class="caption"><slot name="caption"></slot></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - -}); -</script> - -<style lang="scss" scoped> -.adhpbeou { - margin: 1.5em 0; - - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; - - &:empty { - display: none; - } - } - - > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; - color: var(--fgTransparentWeak); - - &:empty { - display: none; - } - } - - > .content { - position: relative; - background: var(--panel); - border: solid 0.5px var(--inputBorder); - border-radius: 6px; - } -} -</style> diff --git a/src/client/components/form/switch.vue b/src/client/components/form/switch.vue deleted file mode 100644 index 85f8b7c870..0000000000 --- a/src/client/components/form/switch.vue +++ /dev/null @@ -1,150 +0,0 @@ -<template> -<div - class="ziffeoms" - :class="{ disabled, checked }" - role="switch" - :aria-checked="checked" - :aria-disabled="disabled" - @click.prevent="toggle" -> - <input - type="checkbox" - ref="input" - :disabled="disabled" - @keydown.enter="toggle" - > - <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff"> - <span class="handle"></span> - </span> - <span class="label"> - <span><slot></slot></span> - <p><slot name="caption"></slot></p> - </span> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - modelValue: { - type: Boolean, - default: false - }, - disabled: { - type: Boolean, - default: false - } - }, - computed: { - checked(): boolean { - return this.modelValue; - } - }, - methods: { - toggle() { - if (this.disabled) return; - this.$emit('update:modelValue', !this.checked); - } - } -}); -</script> - -<style lang="scss" scoped> -.ziffeoms { - position: relative; - display: flex; - cursor: pointer; - transition: all 0.3s; - - &:first-child { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } - - > * { - user-select: none; - } - - > input { - position: absolute; - width: 0; - height: 0; - opacity: 0; - margin: 0; - } - - > .button { - position: relative; - display: inline-block; - flex-shrink: 0; - margin: 0; - width: 36px; - height: 26px; - background: var(--switchBg); - outline: none; - border-radius: 999px; - transition: inherit; - - > .handle { - position: absolute; - top: 0; - bottom: 0; - left: 5px; - margin: auto 0; - border-radius: 100%; - transition: background-color 0.3s, transform 0.3s; - width: 16px; - height: 16px; - background-color: #fff; - } - } - - > .label { - margin-left: 16px; - margin-top: 2px; - display: block; - cursor: pointer; - transition: inherit; - color: var(--fg); - - > span { - display: block; - line-height: 20px; - transition: inherit; - } - - > p { - margin: 0; - color: var(--fgTransparentWeak); - font-size: 90%; - } - } - - &:hover { - > .button { - background-color: var(--accentedBg); - } - } - - &.disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &.checked { - > .button { - background-color: var(--accent); - border-color: var(--accent); - - > .handle { - transform: translateX(10px); - } - } - } -} -</style> diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue deleted file mode 100644 index 048e9032df..0000000000 --- a/src/client/components/form/textarea.vue +++ /dev/null @@ -1,252 +0,0 @@ -<template> -<div class="adhpbeos"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ disabled, focused, tall, pre }"> - <textarea ref="inputEl" - :class="{ code, _monospace: code }" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :placeholder="placeholder" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - ></textarea> - </div> - <div class="caption"><slot name="caption"></slot></div> - - <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import { debounce } from 'throttle-debounce'; - -export default defineComponent({ - components: { - MkButton, - }, - - props: { - modelValue: { - required: true - }, - type: { - type: String, - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - autocomplete: { - required: false - }, - spellcheck: { - required: false - }, - code: { - type: Boolean, - required: false - }, - tall: { - type: Boolean, - required: false, - default: false - }, - pre: { - type: Boolean, - required: false, - default: false - }, - debounce: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['change', 'keydown', 'enter', 'update:modelValue'], - - setup(props, context) { - const { modelValue, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - const onKeydown = (ev: KeyboardEvent) => { - context.emit('keydown', ev); - - if (ev.code === 'Enter') { - context.emit('enter'); - } - }; - - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; - - const debouncedUpdated = debounce(1000, updated); - - watch(modelValue, newValue => { - v.value = newValue; - }); - - watch(v, newValue => { - if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } - } - - invalid.value = inputEl.value.validity.badInput; - }); - - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } - }); - }); - - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - focus, - onInput, - onKeydown, - updated, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.adhpbeos { - > .label { - font-size: 0.85em; - padding: 0 0 8px 12px; - user-select: none; - - &:empty { - display: none; - } - } - - > .caption { - font-size: 0.8em; - padding: 8px 0 0 12px; - color: var(--fgTransparentWeak); - - &:empty { - display: none; - } - } - - > .input { - position: relative; - - > textarea { - appearance: none; - -webkit-appearance: none; - display: block; - width: 100%; - min-width: 100%; - max-width: 100%; - min-height: 130px; - margin: 0; - padding: 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 0.5px var(--inputBorder); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - transition: border-color 0.1s ease-out; - - &:hover { - border-color: var(--inputBorderHover); - } - } - - &.focused { - > textarea { - border-color: var(--accent); - } - } - - &.disabled { - opacity: 0.7; - - &, * { - cursor: not-allowed !important; - } - } - - &.tall { - > textarea { - min-height: 200px; - } - } - - &.pre { - > textarea { - white-space: pre; - } - } - } -} -</style> diff --git a/src/client/components/formula-core.vue b/src/client/components/formula-core.vue deleted file mode 100644 index 6e35295ff5..0000000000 --- a/src/client/components/formula-core.vue +++ /dev/null @@ -1,34 +0,0 @@ - -<template> -<div v-if="block" v-html="compiledFormula"></div> -<span v-else v-html="compiledFormula"></span> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as katex from 'katex';import * as os from '@client/os'; - -export default defineComponent({ - props: { - formula: { - type: String, - required: true - }, - block: { - type: Boolean, - required: true - } - }, - computed: { - compiledFormula(): any { - return katex.renderToString(this.formula, { - throwOnError: false - } as any); - } - } -}); -</script> - -<style> -@import "../../../node_modules/katex/dist/katex.min.css"; -</style> diff --git a/src/client/components/formula.vue b/src/client/components/formula.vue deleted file mode 100644 index 6722ce38a1..0000000000 --- a/src/client/components/formula.vue +++ /dev/null @@ -1,23 +0,0 @@ -<template> -<XFormula :formula="formula" :block="block" /> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@client/os'; - -export default defineComponent({ - components: { - XFormula: defineAsyncComponent(() => import('./formula-core.vue')) - }, - props: { - formula: { - type: String, - required: true - }, - block: { - type: Boolean, - required: true - } - } -}); -</script> diff --git a/src/client/components/gallery-post-preview.vue b/src/client/components/gallery-post-preview.vue deleted file mode 100644 index 5c3bdb1349..0000000000 --- a/src/client/components/gallery-post-preview.vue +++ /dev/null @@ -1,126 +0,0 @@ -<template> -<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1"> - <div class="thumbnail"> - <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> - </div> - <article> - <header> - <MkAvatar :user="post.user" class="avatar"/> - </header> - <footer> - <span class="title">{{ post.title }}</span> - </footer> - </article> -</MkA> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { userName } from '@client/filters/user'; -import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - ImgWithBlurhash - }, - props: { - post: { - type: Object, - required: true - }, - }, - methods: { - userName - } -}); -</script> - -<style lang="scss" scoped> -.ttasepnz { - display: block; - position: relative; - height: 200px; - - &:hover { - text-decoration: none; - color: var(--accent); - - > .thumbnail { - transform: scale(1.1); - } - - > article { - > footer { - &:before { - opacity: 1; - } - } - } - } - - > .thumbnail { - width: 100%; - height: 100%; - position: absolute; - transition: all 0.5s ease; - - > .img { - width: 100%; - height: 100%; - object-fit: cover; - } - } - - > article { - position: absolute; - z-index: 1; - width: 100%; - height: 100%; - - > header { - position: absolute; - top: 0; - width: 100%; - padding: 12px; - box-sizing: border-box; - display: flex; - - > .avatar { - margin-left: auto; - width: 32px; - height: 32px; - } - } - - > footer { - position: absolute; - bottom: 0; - width: 100%; - padding: 16px; - box-sizing: border-box; - color: #fff; - text-shadow: 0 0 8px #000; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); - - &:before { - content: ""; - display: block; - position: absolute; - z-index: -1; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(rgba(0, 0, 0, 0.4), transparent); - opacity: 0; - transition: opacity 0.5s ease; - } - - > .title { - font-weight: bold; - } - } - } -} -</style> diff --git a/src/client/components/global/a.vue b/src/client/components/global/a.vue deleted file mode 100644 index 952dfb1841..0000000000 --- a/src/client/components/global/a.vue +++ /dev/null @@ -1,138 +0,0 @@ -<template> -<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu"> - <slot></slot> -</a> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { router } from '@client/router'; -import { url } from '@client/config'; -import { popout } from '@client/scripts/popout'; -import { ColdDeviceStorage } from '@client/store'; - -export default defineComponent({ - inject: { - navHook: { - default: null - }, - sideViewHook: { - default: null - } - }, - - props: { - to: { - type: String, - required: true, - }, - activeClass: { - type: String, - required: false, - }, - behavior: { - type: String, - required: false, - }, - }, - - computed: { - active() { - if (this.activeClass == null) return false; - const resolved = router.resolve(this.to); - if (resolved.path == this.$route.path) return true; - if (resolved.name == null) return false; - if (this.$route.name == null) return false; - return resolved.name == this.$route.name; - } - }, - - methods: { - onContextmenu(e) { - if (window.getSelection().toString() !== '') return; - os.contextMenu([{ - type: 'label', - text: this.to, - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(this.to); - } - }, this.sideViewHook ? { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.sideViewHook(this.to); - } - } : undefined, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: () => { - this.$router.push(this.to); - } - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.to, '_blank'); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(`${url}${this.to}`); - } - }], e); - }, - - window() { - os.pageWindow(this.to); - }, - - modalWindow() { - os.modalPageWindow(this.to); - }, - - popout() { - popout(this.to); - }, - - nav() { - if (this.behavior === 'browser') { - location.href = this.to; - return; - } - - if (this.to.startsWith('/my/messaging')) { - if (ColdDeviceStorage.get('chatOpenBehavior') === 'window') return this.window(); - if (ColdDeviceStorage.get('chatOpenBehavior') === 'popout') return this.popout(); - } - - if (this.behavior) { - if (this.behavior === 'window') { - return this.window(); - } else if (this.behavior === 'modalWindow') { - return this.modalWindow(); - } - } - - if (this.navHook) { - this.navHook(this.to); - } else { - if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') { - return this.sideViewHook(this.to); - } - - if (this.$router.currentRoute.value.path === this.to) { - window.scroll({ top: 0, behavior: 'smooth' }); - } else { - this.$router.push(this.to); - } - } - } - } -}); -</script> diff --git a/src/client/components/global/acct.vue b/src/client/components/global/acct.vue deleted file mode 100644 index 70f2954cb0..0000000000 --- a/src/client/components/global/acct.vue +++ /dev/null @@ -1,38 +0,0 @@ -<template> -<span class="mk-acct"> - <span class="name">@{{ user.username }}</span> - <span class="host" v-if="user.host || detail || $store.state.showFullAcct">@{{ user.host || host }}</span> -</span> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { toUnicode } from 'punycode/'; -import { host } from '@client/config'; - -export default defineComponent({ - props: { - user: { - type: Object, - required: true - }, - detail: { - type: Boolean, - default: false - }, - }, - data() { - return { - host: toUnicode(host), - }; - } -}); -</script> - -<style lang="scss" scoped> -.mk-acct { - > .host { - opacity: 0.5; - } -} -</style> diff --git a/src/client/components/global/ad.vue b/src/client/components/global/ad.vue deleted file mode 100644 index 8397b2229e..0000000000 --- a/src/client/components/global/ad.vue +++ /dev/null @@ -1,200 +0,0 @@ -<template> -<div class="qiivuoyo" v-if="ad"> - <div class="main" :class="ad.place" v-if="!showMenu"> - <a :href="ad.url" target="_blank"> - <img :src="ad.imageUrl"> - <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button> - </a> - </div> - <div class="menu" v-else> - <div class="body"> - <div>Ads by {{ host }}</div> - <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>--> - <MkButton v-if="ad.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton> - <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button> - </div> - </div> -</div> -<div v-else></div> -</template> - -<script lang="ts"> -import { defineComponent, ref } from 'vue'; -import { Instance, instance } from '@client/instance'; -import { host } from '@client/config'; -import MkButton from '@client/components/ui/button.vue'; -import { defaultStore } from '@client/store'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkButton - }, - - props: { - prefer: { - type: Array, - required: true - }, - specify: { - type: Object, - required: false - }, - }, - - setup(props) { - const showMenu = ref(false); - const toggleMenu = () => { - showMenu.value = !showMenu.value; - }; - - const choseAd = (): Instance['ads'][number] | null => { - if (props.specify) { - return props.specify as Instance['ads'][number]; - } - - const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { - ...ad, - ratio: 0 - } : ad); - - let ads = allAds.filter(ad => props.prefer.includes(ad.place)); - - if (ads.length === 0) { - ads = allAds.filter(ad => ad.place === 'square'); - } - - const lowPriorityAds = ads.filter(ad => ad.ratio === 0); - ads = ads.filter(ad => ad.ratio !== 0); - - if (ads.length === 0) { - if (lowPriorityAds.length !== 0) { - return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)]; - } else { - return null; - } - } - - const totalFactor = ads.reduce((a, b) => a + b.ratio, 0); - const r = Math.random() * totalFactor; - - let stackedFactor = 0; - for (const ad of ads) { - if (r >= stackedFactor && r <= stackedFactor + ad.ratio) { - return ad; - } else { - stackedFactor += ad.ratio; - } - } - - return null; - }; - - const chosen = ref(choseAd()); - - const reduceFrequency = () => { - if (chosen.value == null) return; - if (defaultStore.state.mutedAds.includes(chosen.value.id)) return; - defaultStore.push('mutedAds', chosen.value.id); - os.success(); - chosen.value = choseAd(); - showMenu.value = false; - }; - - return { - ad: chosen, - showMenu, - toggleMenu, - host, - reduceFrequency, - }; - } -}); -</script> - -<style lang="scss" scoped> -.qiivuoyo { - background-size: auto auto; - background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px ); - - > .main { - text-align: center; - - > a { - display: inline-block; - position: relative; - vertical-align: bottom; - - &:hover { - > img { - filter: contrast(120%); - } - } - - > img { - display: block; - object-fit: contain; - margin: auto; - } - - > .menu { - position: absolute; - top: 0; - right: 0; - background: var(--panel); - } - } - - &.square { - > a , - > a > img { - max-width: min(300px, 100%); - max-height: 300px; - } - } - - &.horizontal { - padding: 8px; - - > a , - > a > img { - max-width: min(600px, 100%); - max-height: 80px; - } - } - - &.horizontal-big { - padding: 8px; - - > a , - > a > img { - max-width: min(600px, 100%); - max-height: 250px; - } - } - - &.vertical { - > a , - > a > img { - max-width: min(100px, 100%); - } - } - } - - > .menu { - padding: 8px; - text-align: center; - - > .body { - padding: 8px; - margin: 0 auto; - max-width: 400px; - border: solid 1px var(--divider); - - > .button { - margin: 8px auto; - } - } - } -} -</style> diff --git a/src/client/components/global/avatar.vue b/src/client/components/global/avatar.vue deleted file mode 100644 index 395ed5d8ce..0000000000 --- a/src/client/components/global/avatar.vue +++ /dev/null @@ -1,163 +0,0 @@ -<template> -<span class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> - <img class="inner" :src="url" decoding="async"/> - <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> -</span> -<MkA class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> - <img class="inner" :src="url" decoding="async"/> - <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> -</MkA> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import { extractAvgColorFromBlurhash } from '@client/scripts/extract-avg-color-from-blurhash'; -import { acct, userPage } from '@client/filters/user'; -import MkUserOnlineIndicator from '@client/components/user-online-indicator.vue'; - -export default defineComponent({ - components: { - MkUserOnlineIndicator - }, - props: { - user: { - type: Object, - required: true - }, - target: { - required: false, - default: null - }, - disableLink: { - required: false, - default: false - }, - disablePreview: { - required: false, - default: false - }, - showIndicator: { - required: false, - default: false - } - }, - emits: ['click'], - computed: { - cat(): boolean { - return this.user.isCat; - }, - url(): string { - return this.$store.state.disableShowingAnimatedImages - ? getStaticImageUrl(this.user.avatarUrl) - : this.user.avatarUrl; - }, - }, - watch: { - 'user.avatarBlurhash'() { - if (this.$el == null) return; - this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); - } - }, - mounted() { - this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash); - }, - methods: { - onClick(e) { - this.$emit('click', e); - }, - acct, - userPage - } -}); -</script> - -<style lang="scss" scoped> -@keyframes earwiggleleft { - from { transform: rotate(37.6deg) skew(30deg); } - 25% { transform: rotate(10deg) skew(30deg); } - 50% { transform: rotate(20deg) skew(30deg); } - 75% { transform: rotate(0deg) skew(30deg); } - to { transform: rotate(37.6deg) skew(30deg); } -} - -@keyframes earwiggleright { - from { transform: rotate(-37.6deg) skew(-30deg); } - 30% { transform: rotate(-10deg) skew(-30deg); } - 55% { transform: rotate(-20deg) skew(-30deg); } - 75% { transform: rotate(0deg) skew(-30deg); } - to { transform: rotate(-37.6deg) skew(-30deg); } -} - -.eiwwqkts { - position: relative; - display: inline-block; - vertical-align: bottom; - flex-shrink: 0; - border-radius: 100%; - line-height: 16px; - - > .inner { - position: absolute; - bottom: 0; - left: 0; - right: 0; - top: 0; - border-radius: 100%; - z-index: 1; - overflow: hidden; - object-fit: cover; - width: 100%; - height: 100%; - } - - > .indicator { - position: absolute; - z-index: 1; - bottom: 0; - left: 0; - width: 20%; - height: 20%; - } - - &.square { - border-radius: 20%; - - > .inner { - border-radius: 20%; - } - } - - &.cat { - &:before, &:after { - background: #df548f; - border: solid 4px currentColor; - box-sizing: border-box; - content: ''; - display: inline-block; - height: 50%; - width: 50%; - } - - &:before { - border-radius: 0 75% 75%; - transform: rotate(37.5deg) skew(30deg); - } - - &:after { - border-radius: 75% 0 75% 75%; - transform: rotate(-37.5deg) skew(-30deg); - } - - &:hover { - &:before { - animation: earwiggleleft 1s infinite; - } - - &:after { - animation: earwiggleright 1s infinite; - } - } - } -} -</style> diff --git a/src/client/components/global/ellipsis.vue b/src/client/components/global/ellipsis.vue deleted file mode 100644 index 0a46f486d6..0000000000 --- a/src/client/components/global/ellipsis.vue +++ /dev/null @@ -1,34 +0,0 @@ -<template> - <span class="mk-ellipsis"> - <span>.</span><span>.</span><span>.</span> - </span> -</template> - -<style lang="scss" scoped> -.mk-ellipsis { - > span { - animation: ellipsis 1.4s infinite ease-in-out both; - - &:nth-child(1) { - animation-delay: 0s; - } - - &:nth-child(2) { - animation-delay: 0.16s; - } - - &:nth-child(3) { - animation-delay: 0.32s; - } - } -} - -@keyframes ellipsis { - 0%, 80%, 100% { - opacity: 1; - } - 40% { - opacity: 0; - } -} -</style> diff --git a/src/client/components/global/emoji.vue b/src/client/components/global/emoji.vue deleted file mode 100644 index f92e35c38f..0000000000 --- a/src/client/components/global/emoji.vue +++ /dev/null @@ -1,125 +0,0 @@ -<template> -<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/> -<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/> -<span v-else-if="char && useOsNativeEmojis">{{ char }}</span> -<span v-else>{{ emoji }}</span> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import { twemojiSvgBase } from '@client/../misc/twemoji-base'; - -export default defineComponent({ - props: { - emoji: { - type: String, - required: true - }, - normal: { - type: Boolean, - required: false, - default: false - }, - noStyle: { - type: Boolean, - required: false, - default: false - }, - customEmojis: { - required: false - }, - isReaction: { - type: Boolean, - default: false - }, - }, - - data() { - return { - url: null, - char: null, - customEmoji: null - } - }, - - computed: { - isCustom(): boolean { - return this.emoji.startsWith(':'); - }, - - alt(): string { - return this.customEmoji ? `:${this.customEmoji.name}:` : this.char; - }, - - useOsNativeEmojis(): boolean { - return this.$store.state.useOsNativeEmojis && !this.isReaction; - }, - - ce() { - return this.customEmojis || this.$instance?.emojis || []; - } - }, - - watch: { - ce: { - handler() { - if (this.isCustom) { - const customEmoji = this.ce.find(x => x.name === this.emoji.substr(1, this.emoji.length - 2)); - if (customEmoji) { - this.customEmoji = customEmoji; - this.url = this.$store.state.disableShowingAnimatedImages - ? getStaticImageUrl(customEmoji.url) - : customEmoji.url; - } - } - }, - immediate: true - }, - }, - - created() { - if (!this.isCustom) { - this.char = this.emoji; - } - - if (this.char) { - let codes = Array.from(this.char).map(x => x.codePointAt(0).toString(16)); - if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); - codes = codes.filter(x => x && x.length); - - this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`; - } - }, -}); -</script> - -<style lang="scss" scoped> -.mk-emoji { - height: 1.25em; - vertical-align: -0.25em; - - &.custom { - height: 2.5em; - vertical-align: middle; - transition: transform 0.2s ease; - - &:hover { - transform: scale(1.2); - } - - &.normal { - height: 1.25em; - vertical-align: -0.25em; - - &:hover { - transform: none; - } - } - } - - &.noStyle { - height: auto !important; - } -} -</style> diff --git a/src/client/components/global/error.vue b/src/client/components/global/error.vue deleted file mode 100644 index 05a508a653..0000000000 --- a/src/client/components/global/error.vue +++ /dev/null @@ -1,46 +0,0 @@ -<template> -<transition :name="$store.state.animation ? 'zoom' : ''" appear> - <div class="mjndxjcg"> - <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> - <p><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</p> - <MkButton @click="() => $emit('retry')" class="button">{{ $ts.retry }}</MkButton> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - MkButton, - }, - data() { - return { - }; - }, -}); -</script> - -<style lang="scss" scoped> -.mjndxjcg { - padding: 32px; - text-align: center; - - > p { - margin: 0 0 8px 0; - } - - > .button { - margin: 0 auto; - } - - > img { - vertical-align: bottom; - height: 128px; - margin-bottom: 16px; - border-radius: 16px; - } -} -</style> diff --git a/src/client/components/global/header.vue b/src/client/components/global/header.vue deleted file mode 100644 index 526db07fd3..0000000000 --- a/src/client/components/global/header.vue +++ /dev/null @@ -1,360 +0,0 @@ -<template> -<div class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick" ref="el"> - <template v-if="info"> - <div class="titleContainer" @click="showTabsPopup" v-if="!hideTitle"> - <MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> - <i v-else-if="info.icon" class="icon" :class="info.icon"></i> - - <div class="title"> - <MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/> - <div v-else-if="info.title" class="title">{{ info.title }}</div> - <div class="subtitle" v-if="!narrow && info.subtitle"> - {{ info.subtitle }} - </div> - <div class="subtitle activeTab" v-if="narrow && hasTabs"> - {{ info.tabs.find(tab => tab.active)?.title }} - <i class="chevron fas fa-chevron-down"></i> - </div> - </div> - </div> - <div class="tabs" v-if="!narrow || hideTitle"> - <button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title"> - <i v-if="tab.icon" class="icon" :class="tab.icon"></i> - <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> - </button> - </div> - </template> - <div class="buttons right"> - <template v-if="info && info.actions && !narrow"> - <template v-for="action in info.actions"> - <MkButton class="fullButton" v-if="action.asFullButton" @click.stop="action.handler" primary><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> - <button v-else class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button> - </template> - </template> - <button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button> - </div> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue'; -import * as tinycolor from 'tinycolor2'; -import { popupMenu } from '@client/os'; -import { url } from '@client/config'; -import { scrollToTop } from '@client/scripts/scroll'; -import MkButton from '@client/components/ui/button.vue'; -import { i18n } from '@client/i18n'; -import { globalEvents } from '@client/events'; - -export default defineComponent({ - components: { - MkButton - }, - - props: { - info: { - type: Object as PropType<{ - actions?: {}[]; - tabs?: {}[]; - }>, - required: true - }, - menu: { - required: false - }, - thin: { - required: false, - default: false - }, - }, - - setup(props) { - const el = ref<HTMLElement>(null); - const bg = ref(null); - const narrow = ref(false); - const height = ref(0); - const hasTabs = computed(() => { - return props.info.tabs && props.info.tabs.length > 0; - }); - const shouldShowMenu = computed(() => { - if (props.info == null) return false; - if (props.info.actions != null && narrow.value) return true; - if (props.info.menu != null) return true; - if (props.info.share != null) return true; - if (props.menu != null) return true; - return false; - }); - - const share = () => { - navigator.share({ - url: url + props.info.path, - ...props.info.share, - }); - }; - - const showMenu = (ev: MouseEvent) => { - let menu = props.info.menu ? props.info.menu() : []; - if (narrow.value && props.info.actions) { - menu = [...props.info.actions.map(x => ({ - text: x.text, - icon: x.icon, - action: x.handler - })), menu.length > 0 ? null : undefined, ...menu]; - } - if (props.info.share) { - if (menu.length > 0) menu.push(null); - menu.push({ - text: i18n.locale.share, - icon: 'fas fa-share-alt', - action: share - }); - } - if (props.menu) { - if (menu.length > 0) menu.push(null); - menu = menu.concat(props.menu); - } - popupMenu(menu, ev.currentTarget || ev.target); - }; - - const showTabsPopup = (ev: MouseEvent) => { - if (!hasTabs.value) return; - if (!narrow.value) return; - ev.preventDefault(); - ev.stopPropagation(); - const menu = props.info.tabs.map(tab => ({ - text: tab.title, - icon: tab.icon, - action: tab.onClick, - })); - popupMenu(menu, ev.currentTarget || ev.target); - }; - - const preventDrag = (ev: TouchEvent) => { - ev.stopPropagation(); - }; - - const onClick = () => { - scrollToTop(el.value, { behavior: 'smooth' }); - }; - - const calcBg = () => { - const rawBg = props.info?.bg || 'var(--bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); - tinyBg.setAlpha(0.85); - bg.value = tinyBg.toRgbString(); - }; - - onMounted(() => { - calcBg(); - globalEvents.on('themeChanged', calcBg); - onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); - }); - - if (el.value.parentElement) { - narrow.value = el.value.parentElement.offsetWidth < 500; - const ro = new ResizeObserver((entries, observer) => { - if (el.value) { - narrow.value = el.value.parentElement.offsetWidth < 500; - } - }); - ro.observe(el.value.parentElement); - onUnmounted(() => { - ro.disconnect(); - }); - } - }); - - return { - el, - bg, - narrow, - height, - hasTabs, - shouldShowMenu, - share, - showMenu, - showTabsPopup, - preventDrag, - onClick, - hideTitle: inject('shouldOmitHeaderTitle', false), - thin_: props.thin || inject('shouldHeaderThin', false) - }; - }, -}); -</script> - -<style lang="scss" scoped> -.fdidabkb { - --height: 60px; - display: flex; - position: sticky; - top: var(--stickyTop, 0); - z-index: 1000; - width: 100%; - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); - border-bottom: solid 0.5px var(--divider); - - &.thin { - --height: 50px; - - > .buttons { - > .button { - font-size: 0.9em; - } - } - } - - &.slim { - text-align: center; - - > .titleContainer { - flex: 1; - margin: 0 auto; - margin-left: var(--height); - - > *:first-child { - margin-left: auto; - } - - > *:last-child { - margin-right: auto; - } - } - } - - > .buttons { - --margin: 8px; - display: flex; - align-items: center; - height: var(--height); - margin: 0 var(--margin); - - &.right { - margin-left: auto; - } - - &:empty { - width: var(--height); - } - - > .button { - display: flex; - align-items: center; - justify-content: center; - height: calc(var(--height) - (var(--margin) * 2)); - width: calc(var(--height) - (var(--margin) * 2)); - box-sizing: border-box; - position: relative; - border-radius: 5px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &.highlighted { - color: var(--accent); - } - } - - > .fullButton { - & + .fullButton { - margin-left: 12px; - } - } - } - - > .titleContainer { - display: flex; - align-items: center; - overflow: auto; - white-space: nowrap; - text-align: left; - font-weight: bold; - flex-shrink: 0; - margin-left: 24px; - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: bottom; - margin: 0 8px; - pointer-events: none; - } - - > .icon { - margin-right: 8px; - } - - > .title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.1; - - > .subtitle { - opacity: 0.6; - font-size: 0.8em; - font-weight: normal; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &.activeTab { - text-align: center; - - > .chevron { - display: inline-block; - margin-left: 6px; - } - } - } - } - } - - > .tabs { - margin-left: 16px; - font-size: 0.8em; - overflow: auto; - white-space: nowrap; - - > .tab { - display: inline-block; - position: relative; - padding: 0 10px; - height: 100%; - font-weight: normal; - opacity: 0.7; - - &:hover { - opacity: 1; - } - - &.active { - opacity: 1; - - &:after { - content: ""; - display: block; - position: absolute; - bottom: 0; - left: 0; - right: 0; - margin: 0 auto; - width: 100%; - height: 3px; - background: var(--accent); - } - } - - > .icon + .title { - margin-left: 8px; - } - } - } -} -</style> diff --git a/src/client/components/global/i18n.ts b/src/client/components/global/i18n.ts deleted file mode 100644 index abf0c96856..0000000000 --- a/src/client/components/global/i18n.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { h, defineComponent } from 'vue'; - -export default defineComponent({ - props: { - src: { - type: String, - required: true, - }, - tag: { - type: String, - required: false, - default: 'span', - }, - textTag: { - type: String, - required: false, - default: null, - }, - }, - render() { - let str = this.src; - const parsed = [] as (string | { arg: string; })[]; - while (true) { - const nextBracketOpen = str.indexOf('{'); - const nextBracketClose = str.indexOf('}'); - - if (nextBracketOpen === -1) { - parsed.push(str); - break; - } else { - if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); - parsed.push({ - arg: str.substring(nextBracketOpen + 1, nextBracketClose) - }); - } - - str = str.substr(nextBracketClose + 1); - } - - return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]())); - } -}); diff --git a/src/client/components/global/loading.vue b/src/client/components/global/loading.vue deleted file mode 100644 index 7bde53c12e..0000000000 --- a/src/client/components/global/loading.vue +++ /dev/null @@ -1,92 +0,0 @@ -<template> -<div class="yxspomdl" :class="{ inline, colored, mini }"> - <div class="ring"></div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - inline: { - type: Boolean, - required: false, - default: false - }, - colored: { - type: Boolean, - required: false, - default: true - }, - mini: { - type: Boolean, - required: false, - default: false - }, - } -}); -</script> - -<style lang="scss" scoped> -@keyframes ring { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} - -.yxspomdl { - padding: 32px; - text-align: center; - cursor: wait; - - --size: 48px; - - &.colored { - color: var(--accent); - } - - &.inline { - display: inline; - padding: 0; - --size: 32px; - } - - &.mini { - padding: 16px; - --size: 32px; - } - - > .ring { - position: relative; - display: inline-block; - vertical-align: middle; - - &:before, - &:after { - content: " "; - display: block; - box-sizing: border-box; - width: var(--size); - height: var(--size); - border-radius: 50%; - border: solid 4px; - } - - &:before { - border-color: currentColor; - opacity: 0.3; - } - - &:after { - position: absolute; - top: 0; - border-color: currentColor transparent transparent transparent; - animation: ring 0.5s linear infinite; - } - } -} -</style> diff --git a/src/client/components/global/misskey-flavored-markdown.vue b/src/client/components/global/misskey-flavored-markdown.vue deleted file mode 100644 index c4f75bee93..0000000000 --- a/src/client/components/global/misskey-flavored-markdown.vue +++ /dev/null @@ -1,157 +0,0 @@ -<template> -<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MfmCore from '@client/components/mfm'; - -export default defineComponent({ - components: { - MfmCore - } -}); -</script> - -<style lang="scss"> -._mfm_blur_ { - filter: blur(6px); - transition: filter 0.3s; - - &:hover { - filter: blur(0px); - } -} - -@keyframes mfm-spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -@keyframes mfm-spinX { - 0% { transform: perspective(128px) rotateX(0deg); } - 100% { transform: perspective(128px) rotateX(360deg); } -} - -@keyframes mfm-spinY { - 0% { transform: perspective(128px) rotateY(0deg); } - 100% { transform: perspective(128px) rotateY(360deg); } -} - -@keyframes mfm-jump { - 0% { transform: translateY(0); } - 25% { transform: translateY(-16px); } - 50% { transform: translateY(0); } - 75% { transform: translateY(-8px); } - 100% { transform: translateY(0); } -} - -@keyframes mfm-bounce { - 0% { transform: translateY(0) scale(1, 1); } - 25% { transform: translateY(-16px) scale(1, 1); } - 50% { transform: translateY(0) scale(1, 1); } - 75% { transform: translateY(0) scale(1.5, 0.75); } - 100% { transform: translateY(0) scale(1, 1); } -} - -// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`; -// let css = ''; -// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } -@keyframes mfm-twitch { - 0% { transform: translate(7px, -2px) } - 5% { transform: translate(-3px, 1px) } - 10% { transform: translate(-7px, -1px) } - 15% { transform: translate(0px, -1px) } - 20% { transform: translate(-8px, 6px) } - 25% { transform: translate(-4px, -3px) } - 30% { transform: translate(-4px, -6px) } - 35% { transform: translate(-8px, -8px) } - 40% { transform: translate(4px, 6px) } - 45% { transform: translate(-3px, 1px) } - 50% { transform: translate(2px, -10px) } - 55% { transform: translate(-7px, 0px) } - 60% { transform: translate(-2px, 4px) } - 65% { transform: translate(3px, -8px) } - 70% { transform: translate(6px, 7px) } - 75% { transform: translate(-7px, -2px) } - 80% { transform: translate(-7px, -8px) } - 85% { transform: translate(9px, 3px) } - 90% { transform: translate(-3px, -2px) } - 95% { transform: translate(-10px, 2px) } - 100% { transform: translate(-2px, -6px) } -} - -// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`; -// let css = ''; -// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; } -@keyframes mfm-shake { - 0% { transform: translate(-3px, -1px) rotate(-8deg) } - 5% { transform: translate(0px, -1px) rotate(-10deg) } - 10% { transform: translate(1px, -3px) rotate(0deg) } - 15% { transform: translate(1px, 1px) rotate(11deg) } - 20% { transform: translate(-2px, 1px) rotate(1deg) } - 25% { transform: translate(-1px, -2px) rotate(-2deg) } - 30% { transform: translate(-1px, 2px) rotate(-3deg) } - 35% { transform: translate(2px, 1px) rotate(6deg) } - 40% { transform: translate(-2px, -3px) rotate(-9deg) } - 45% { transform: translate(0px, -1px) rotate(-12deg) } - 50% { transform: translate(1px, 2px) rotate(10deg) } - 55% { transform: translate(0px, -3px) rotate(8deg) } - 60% { transform: translate(1px, -1px) rotate(8deg) } - 65% { transform: translate(0px, -1px) rotate(-7deg) } - 70% { transform: translate(-1px, -3px) rotate(6deg) } - 75% { transform: translate(0px, -2px) rotate(4deg) } - 80% { transform: translate(-2px, -1px) rotate(3deg) } - 85% { transform: translate(1px, -3px) rotate(-10deg) } - 90% { transform: translate(1px, 0px) rotate(3deg) } - 95% { transform: translate(-2px, 0px) rotate(-3deg) } - 100% { transform: translate(2px, 1px) rotate(2deg) } -} - -@keyframes mfm-rubberBand { - from { transform: scale3d(1, 1, 1); } - 30% { transform: scale3d(1.25, 0.75, 1); } - 40% { transform: scale3d(0.75, 1.25, 1); } - 50% { transform: scale3d(1.15, 0.85, 1); } - 65% { transform: scale3d(0.95, 1.05, 1); } - 75% { transform: scale3d(1.05, 0.95, 1); } - to { transform: scale3d(1, 1, 1); } -} - -@keyframes mfm-rainbow { - 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); } - 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); } -} -</style> - -<style lang="scss" scoped> -.havbbuyv { - white-space: pre-wrap; - - &.nowrap { - white-space: pre; - word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html - overflow: hidden; - text-overflow: ellipsis; - } - - ::v-deep(.quote) { - display: block; - margin: 8px; - padding: 6px 0 6px 12px; - color: var(--fg); - border-left: solid 3px var(--fg); - opacity: 0.7; - } - - ::v-deep(pre) { - font-size: 0.8em; - } - - > ::v-deep(code) { - font-size: 0.8em; - word-break: break-all; - padding: 4px 6px; - } -} -</style> diff --git a/src/client/components/global/spacer.vue b/src/client/components/global/spacer.vue deleted file mode 100644 index 1129d54c71..0000000000 --- a/src/client/components/global/spacer.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> -<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }"> - <div ref="content" :class="$style.content"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; - -export default defineComponent({ - props: { - contentMax: { - type: Number, - required: false, - default: null, - } - }, - - setup(props, context) { - let ro: ResizeObserver; - const root = ref<HTMLElement>(null); - const content = ref<HTMLElement>(null); - const margin = ref(0); - const adjust = (rect: { width: number; height: number; }) => { - if (rect.width > (props.contentMax || 500)) { - margin.value = 32; - } else { - margin.value = 12; - } - }; - - onMounted(() => { - ro = new ResizeObserver((entries) => { - /* iOSが対応していない - adjust({ - width: entries[0].borderBoxSize[0].inlineSize, - height: entries[0].borderBoxSize[0].blockSize, - }); - */ - adjust({ - width: root.value.offsetWidth, - height: root.value.offsetHeight, - }); - }); - ro.observe(root.value); - - if (props.contentMax) { - content.value.style.maxWidth = `${props.contentMax}px`; - } - }); - - onUnmounted(() => { - ro.disconnect(); - }); - - return { - root, - content, - margin, - }; - }, -}); -</script> - -<style lang="scss" module> -.root { - box-sizing: border-box; - width: 100%; -} - -.content { - margin: 0 auto; -} -</style> diff --git a/src/client/components/global/sticky-container.vue b/src/client/components/global/sticky-container.vue deleted file mode 100644 index 859b2c1d73..0000000000 --- a/src/client/components/global/sticky-container.vue +++ /dev/null @@ -1,74 +0,0 @@ -<template> -<div ref="rootEl"> - <slot name="header"></slot> - <div ref="bodyEl"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; - -export default defineComponent({ - props: { - autoSticky: { - type: Boolean, - required: false, - default: false, - }, - }, - - setup(props, context) { - const rootEl = ref<HTMLElement>(null); - const bodyEl = ref<HTMLElement>(null); - - const calc = () => { - const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; - - const header = rootEl.value.children[0]; - if (header === bodyEl.value) { - bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); - } else { - bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); - - if (props.autoSticky) { - header.style.setProperty('--stickyTop', currentStickyTop); - header.style.position = 'sticky'; - header.style.top = 'var(--stickyTop)'; - header.style.zIndex = '1'; - } - } - }; - - onMounted(() => { - calc(); - - const observer = new MutationObserver(() => { - setTimeout(() => { - calc(); - }, 100); - }); - - observer.observe(rootEl.value, { - attributes: false, - childList: true, - subtree: false, - }); - - onUnmounted(() => { - observer.disconnect(); - }); - }); - - return { - rootEl, - bodyEl, - }; - }, -}); -</script> - -<style lang="scss" module> - -</style> diff --git a/src/client/components/global/time.vue b/src/client/components/global/time.vue deleted file mode 100644 index 6a330a2307..0000000000 --- a/src/client/components/global/time.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<time :title="absolute"> - <template v-if="mode == 'relative'">{{ relative }}</template> - <template v-else-if="mode == 'absolute'">{{ absolute }}</template> - <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template> -</time> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - time: { - type: [Date, String], - required: true - }, - mode: { - type: String, - default: 'relative' - } - }, - data() { - return { - tickId: null, - now: new Date() - }; - }, - computed: { - _time(): Date { - return typeof this.time == 'string' ? new Date(this.time) : this.time; - }, - absolute(): string { - return this._time.toLocaleString(); - }, - relative(): string { - const time = this._time; - const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/; - return ( - ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) : - ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) : - ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) : - ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) : - ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) : - ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : - ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : - ago >= -1 ? this.$ts._ago.justNow : - ago < -1 ? this.$ts._ago.future : - this.$ts._ago.unknown); - } - }, - created() { - if (this.mode == 'relative' || this.mode == 'detail') { - this.tickId = window.requestAnimationFrame(this.tick); - } - }, - unmounted() { - if (this.mode === 'relative' || this.mode === 'detail') { - window.clearTimeout(this.tickId); - } - }, - methods: { - tick() { - // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する - this.now = new Date(); - - this.tickId = setTimeout(() => { - window.requestAnimationFrame(this.tick); - }, 10000); - } - } -}); -</script> diff --git a/src/client/components/global/url.vue b/src/client/components/global/url.vue deleted file mode 100644 index 218729882d..0000000000 --- a/src/client/components/global/url.vue +++ /dev/null @@ -1,142 +0,0 @@ -<template> -<component :is="self ? 'MkA' : 'a'" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" - @mouseover="onMouseover" - @mouseleave="onMouseleave" - @contextmenu.stop="() => {}" -> - <template v-if="!self"> - <span class="schema">{{ schema }}//</span> - <span class="hostname">{{ hostname }}</span> - <span class="port" v-if="port != ''">:{{ port }}</span> - </template> - <template v-if="pathname === '/' && self"> - <span class="self">{{ hostname }}</span> - </template> - <span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span> - <span class="query">{{ query }}</span> - <span class="hash">{{ hash }}</span> - <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> -</component> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { toUnicode as decodePunycode } from 'punycode/'; -import { url as local } from '@client/config'; -import { isDeviceTouch } from '@client/scripts/is-device-touch'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - url: { - type: String, - required: true, - }, - rel: { - type: String, - required: false, - } - }, - data() { - const self = this.url.startsWith(local); - return { - local, - schema: null as string | null, - hostname: null as string | null, - port: null as string | null, - pathname: null as string | null, - query: null as string | null, - hash: null as string | null, - self: self, - attr: self ? 'to' : 'href', - target: self ? null : '_blank', - showTimer: null, - hideTimer: null, - checkTimer: null, - close: null, - }; - }, - created() { - const url = new URL(this.url); - this.schema = url.protocol; - this.hostname = decodePunycode(url.hostname); - this.port = url.port; - this.pathname = decodeURIComponent(url.pathname); - this.query = decodeURIComponent(url.search); - this.hash = decodeURIComponent(url.hash); - }, - methods: { - async showPreview() { - if (!document.body.contains(this.$el)) return; - if (this.close) return; - - const { dispose } = await os.popup(import('@client/components/url-preview-popup.vue'), { - url: this.url, - source: this.$el - }); - - this.close = () => { - dispose(); - }; - - this.checkTimer = setInterval(() => { - if (!document.body.contains(this.$el)) this.closePreview(); - }, 1000); - }, - closePreview() { - if (this.close) { - clearInterval(this.checkTimer); - this.close(); - this.close = null; - } - }, - onMouseover() { - if (isDeviceTouch) return; - clearTimeout(this.showTimer); - clearTimeout(this.hideTimer); - this.showTimer = setTimeout(this.showPreview, 500); - }, - onMouseleave() { - if (isDeviceTouch) return; - clearTimeout(this.showTimer); - clearTimeout(this.hideTimer); - this.hideTimer = setTimeout(this.closePreview, 500); - } - } -}); -</script> - -<style lang="scss" scoped> -.ieqqeuvs { - word-break: break-all; - - > .icon { - padding-left: 2px; - font-size: .9em; - } - - > .self { - font-weight: bold; - } - - > .schema { - opacity: 0.5; - } - - > .hostname { - font-weight: bold; - } - - > .pathname { - opacity: 0.8; - } - - > .query { - opacity: 0.5; - } - - > .hash { - font-style: italic; - } -} -</style> diff --git a/src/client/components/global/user-name.vue b/src/client/components/global/user-name.vue deleted file mode 100644 index bc93a8ea30..0000000000 --- a/src/client/components/global/user-name.vue +++ /dev/null @@ -1,20 +0,0 @@ -<template> -<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - user: { - type: Object, - required: true - }, - nowrap: { - type: Boolean, - default: true - }, - } -}); -</script> diff --git a/src/client/components/google.vue b/src/client/components/google.vue deleted file mode 100644 index be724f038d..0000000000 --- a/src/client/components/google.vue +++ /dev/null @@ -1,64 +0,0 @@ -<template> -<div class="mk-google"> - <input type="search" v-model="query" :placeholder="q"> - <button @click="search"><i class="fas fa-search"></i> {{ $ts.search }}</button> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - q: { - type: String, - required: true, - } - }, - data() { - return { - query: null, - }; - }, - mounted() { - this.query = this.q; - }, - methods: { - search() { - window.open(`https://www.google.com/search?q=${this.query}`, '_blank'); - } - } -}); -</script> - -<style lang="scss" scoped> -.mk-google { - display: flex; - margin: 8px 0; - - > input { - flex-shrink: 1; - padding: 10px; - width: 100%; - height: 40px; - font-size: 16px; - border: solid 1px var(--divider); - border-radius: 4px 0 0 4px; - -webkit-appearance: textfield; - } - - > button { - flex-shrink: 0; - margin: 0; - padding: 0 16px; - border: solid 1px var(--divider); - border-left: none; - border-radius: 0 4px 4px 0; - - &:active { - box-shadow: 0 2px 4px rgba(#000, 0.15) inset; - } - } -} -</style> diff --git a/src/client/components/image-viewer.vue b/src/client/components/image-viewer.vue deleted file mode 100644 index 7701ae926f..0000000000 --- a/src/client/components/image-viewer.vue +++ /dev/null @@ -1,85 +0,0 @@ -<template> -<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> - <div class="xubzgfga"> - <header>{{ image.name }}</header> - <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> - <footer> - <span>{{ image.type }}</span> - <span>{{ bytes(image.size) }}</span> - <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> - </footer> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import bytes from '@client/filters/bytes'; -import number from '@client/filters/number'; -import MkModal from '@client/components/ui/modal.vue'; - -export default defineComponent({ - components: { - MkModal, - }, - - props: { - image: { - type: Object, - required: true - }, - }, - - emits: ['closed'], - - methods: { - bytes, - number, - } -}); -</script> - -<style lang="scss" scoped> -.xubzgfga { - display: flex; - flex-direction: column; - height: 100%; - - > header, - > footer { - align-self: center; - display: inline-block; - padding: 6px 9px; - font-size: 90%; - background: rgba(0, 0, 0, 0.5); - border-radius: 6px; - color: #fff; - } - - > header { - margin-bottom: 8px; - opacity: 0.9; - } - - > img { - display: block; - flex: 1; - min-height: 0; - object-fit: contain; - width: 100%; - cursor: zoom-out; - image-orientation: from-image; - } - - > footer { - margin-top: 8px; - opacity: 0.8; - - > span + span { - margin-left: 0.5em; - padding-left: 0.5em; - border-left: solid 1px rgba(255, 255, 255, 0.5); - } - } -} -</style> diff --git a/src/client/components/img-with-blurhash.vue b/src/client/components/img-with-blurhash.vue deleted file mode 100644 index 7e80b00208..0000000000 --- a/src/client/components/img-with-blurhash.vue +++ /dev/null @@ -1,100 +0,0 @@ -<template> -<div class="xubzgfgb" :class="{ cover }" :title="title"> - <canvas ref="canvas" :width="size" :height="size" :title="title" v-if="!loaded"/> - <img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { decode } from 'blurhash'; - -export default defineComponent({ - props: { - src: { - type: String, - required: false, - default: null - }, - hash: { - type: String, - required: true - }, - alt: { - type: String, - required: false, - default: '', - }, - title: { - type: String, - required: false, - default: null, - }, - size: { - type: Number, - required: false, - default: 64 - }, - cover: { - type: Boolean, - required: false, - default: true, - } - }, - - data() { - return { - loaded: false, - }; - }, - - mounted() { - this.draw(); - }, - - methods: { - draw() { - if (this.hash == null) return; - const pixels = decode(this.hash, this.size, this.size); - const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d'); - const imageData = ctx!.createImageData(this.size, this.size); - imageData.data.set(pixels); - ctx!.putImageData(imageData, 0, 0); - }, - - onLoad() { - this.loaded = true; - } - } -}); -</script> - -<style lang="scss" scoped> -.xubzgfgb { - position: relative; - width: 100%; - height: 100%; - - > canvas, - > img { - display: block; - width: 100%; - height: 100%; - } - - > canvas { - position: absolute; - object-fit: cover; - } - - > img { - object-fit: contain; - } - - &.cover { - > img { - object-fit: cover; - } - } -} -</style> diff --git a/src/client/components/index.ts b/src/client/components/index.ts deleted file mode 100644 index 2340b228f8..0000000000 --- a/src/client/components/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { App } from 'vue'; - -import mfm from './global/misskey-flavored-markdown.vue'; -import a from './global/a.vue'; -import acct from './global/acct.vue'; -import avatar from './global/avatar.vue'; -import emoji from './global/emoji.vue'; -import userName from './global/user-name.vue'; -import ellipsis from './global/ellipsis.vue'; -import time from './global/time.vue'; -import url from './global/url.vue'; -import i18n from './global/i18n'; -import loading from './global/loading.vue'; -import error from './global/error.vue'; -import ad from './global/ad.vue'; -import header from './global/header.vue'; -import spacer from './global/spacer.vue'; -import stickyContainer from './global/sticky-container.vue'; - -export default function(app: App) { - app.component('I18n', i18n); - app.component('Mfm', mfm); - app.component('MkA', a); - app.component('MkAcct', acct); - app.component('MkAvatar', avatar); - app.component('MkEmoji', emoji); - app.component('MkUserName', userName); - app.component('MkEllipsis', ellipsis); - app.component('MkTime', time); - app.component('MkUrl', url); - app.component('MkLoading', loading); - app.component('MkError', error); - app.component('MkAd', ad); - app.component('MkHeader', header); - app.component('MkSpacer', spacer); - app.component('MkStickyContainer', stickyContainer); -} diff --git a/src/client/components/instance-stats.vue b/src/client/components/instance-stats.vue deleted file mode 100644 index fd0b75609f..0000000000 --- a/src/client/components/instance-stats.vue +++ /dev/null @@ -1,80 +0,0 @@ -<template> -<div class="zbcjwnqg" style="margin-top: -8px;"> - <div class="selects" style="display: flex;"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup :label="$ts.federation"> - <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option> - <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option> - </optgroup> - <optgroup :label="$ts.users"> - <option value="users">{{ $ts._charts.usersIncDec }}</option> - <option value="users-total">{{ $ts._charts.usersTotal }}</option> - <option value="active-users">{{ $ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="$ts.notes"> - <option value="notes">{{ $ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> - <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ $ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="$ts.drive"> - <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> - <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option> - <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> - <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ $ts.perHour }}</option> - <option value="day">{{ $ts.perDay }}</option> - </MkSelect> - </div> - <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, ref, watch } from 'vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkChart from '@client/components/chart.vue'; -import * as os from '@client/os'; -import { defaultStore } from '@client/store'; - -export default defineComponent({ - components: { - MkSelect, - MkChart, - }, - - props: { - chartLimit: { - type: Number, - required: false, - default: 90 - }, - detailed: { - type: Boolean, - required: false, - default: false - }, - }, - - setup() { - const chartSpan = ref<'hour' | 'day'>('hour'); - const chartSrc = ref('notes'); - - return { - chartSrc, - chartSpan, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.zbcjwnqg { - > .selects { - padding: 8px 16px 0 16px; - } -} -</style> diff --git a/src/client/components/instance-ticker.vue b/src/client/components/instance-ticker.vue deleted file mode 100644 index 5674174558..0000000000 --- a/src/client/components/instance-ticker.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<div class="hpaizdrt" :style="bg"> - <img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/> - <span class="name">{{ info.name }}</span> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { instanceName } from '@client/config'; - -export default defineComponent({ - props: { - instance: { - type: Object, - required: false - }, - }, - - data() { - return { - info: this.instance || { - faviconUrl: '/favicon.ico', - name: instanceName, - themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content - } - } - }, - - computed: { - bg(): any { - const themeColor = this.info.themeColor || '#777777'; - return { - background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})` - }; - } - } -}); -</script> - -<style lang="scss" scoped> -.hpaizdrt { - $height: 1.1rem; - - height: $height; - border-radius: 4px 0 0 4px; - overflow: hidden; - color: #fff; - - > .icon { - height: 100%; - } - - > .name { - margin-left: 4px; - line-height: $height; - font-size: 0.9em; - vertical-align: top; - font-weight: bold; - } -} -</style> diff --git a/src/client/components/launch-pad.vue b/src/client/components/launch-pad.vue deleted file mode 100644 index 9da62f1e0b..0000000000 --- a/src/client/components/launch-pad.vue +++ /dev/null @@ -1,152 +0,0 @@ -<template> -<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> - <div class="szkkfdyq _popup"> - <div class="main"> - <template v-for="item in items"> - <button v-if="item.action" class="_button" @click="$event => { item.action($event); close(); }" v-click-anime> - <i class="icon" :class="item.icon"></i> - <div class="text">{{ item.text }}</div> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </button> - <MkA v-else :to="item.to" @click.passive="close()" v-click-anime> - <i class="icon" :class="item.icon"></i> - <div class="text">{{ item.text }}</div> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </MkA> - </template> - </div> - <div class="sub"> - <a href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()" v-click-anime> - <i class="fas fa-question-circle icon"></i> - <div class="text">{{ $ts.help }}</div> - </a> - <MkA to="/about" @click.passive="close()" v-click-anime> - <i class="fas fa-info-circle icon"></i> - <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div> - </MkA> - <MkA to="/about-misskey" @click.passive="close()" v-click-anime> - <img src="/static-assets/favicon.png" class="icon"/> - <div class="text">{{ $ts.aboutMisskey }}</div> - </MkA> - </div> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from '@client/components/ui/modal.vue'; -import { menuDef } from '@client/menu'; -import { instanceName } from '@client/config'; - -export default defineComponent({ - components: { - MkModal, - }, - - emits: ['closed'], - - data() { - return { - menuDef: menuDef, - items: [], - instanceName, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - }, - - created() { - this.items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({ - type: def.to ? 'link' : 'button', - text: this.$ts[def.title], - icon: def.icon, - to: def.to, - action: def.action, - indicate: def.indicated, - })); - }, - - methods: { - close() { - this.$refs.modal.close(); - } - } -}); -</script> - -<style lang="scss" scoped> -.szkkfdyq { - width: 100%; - max-height: 100%; - max-width: 800px; - padding: 32px; - box-sizing: border-box; - overflow: auto; - text-align: center; - border-radius: 16px; - - @media (max-width: 500px) { - padding: 16px; - } - - > .main, > .sub { - > * { - position: relative; - display: inline-flex; - flex-direction: column; - align-items: center; - justify-content: center; - vertical-align: bottom; - width: 128px; - height: 128px; - border-radius: var(--radius); - - @media (max-width: 500px) { - width: 100px; - height: 100px; - } - - &:hover { - background: rgba(0, 0, 0, 0.05); - text-decoration: none; - } - - > .icon { - font-size: 26px; - height: 32px; - } - - > .text { - margin-top: 8px; - font-size: 0.9em; - line-height: 1.5em; - } - - > .indicator { - position: absolute; - top: 32px; - left: 32px; - color: var(--indicator); - font-size: 8px; - animation: blink 1s infinite; - - @media (max-width: 500px) { - top: 16px; - left: 16px; - } - } - } - } - - > .sub { - margin-top: 8px; - padding-top: 8px; - border-top: solid 0.5px var(--divider); - } -} -</style> diff --git a/src/client/components/link.vue b/src/client/components/link.vue deleted file mode 100644 index a887410331..0000000000 --- a/src/client/components/link.vue +++ /dev/null @@ -1,92 +0,0 @@ -<template> -<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" - @mouseover="onMouseover" - @mouseleave="onMouseleave" - :title="url" -> - <slot></slot> - <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> -</component> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { url as local } from '@client/config'; -import { isDeviceTouch } from '@client/scripts/is-device-touch'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - url: { - type: String, - required: true, - }, - rel: { - type: String, - required: false, - } - }, - data() { - const self = this.url.startsWith(local); - return { - local, - self: self, - attr: self ? 'to' : 'href', - target: self ? null : '_blank', - showTimer: null, - hideTimer: null, - checkTimer: null, - close: null, - }; - }, - methods: { - async showPreview() { - if (!document.body.contains(this.$el)) return; - if (this.close) return; - - const { dispose } = await os.popup(import('@client/components/url-preview-popup.vue'), { - url: this.url, - source: this.$el - }); - - this.close = () => { - dispose(); - }; - - this.checkTimer = setInterval(() => { - if (!document.body.contains(this.$el)) this.closePreview(); - }, 1000); - }, - closePreview() { - if (this.close) { - clearInterval(this.checkTimer); - this.close(); - this.close = null; - } - }, - onMouseover() { - if (isDeviceTouch) return; - clearTimeout(this.showTimer); - clearTimeout(this.hideTimer); - this.showTimer = setTimeout(this.showPreview, 500); - }, - onMouseleave() { - if (isDeviceTouch) return; - clearTimeout(this.showTimer); - clearTimeout(this.hideTimer); - this.hideTimer = setTimeout(this.closePreview, 500); - } - } -}); -</script> - -<style lang="scss" scoped> -.xlcxczvw { - word-break: break-all; - - > .icon { - padding-left: 2px; - font-size: .9em; - } -} -</style> diff --git a/src/client/components/media-banner.vue b/src/client/components/media-banner.vue deleted file mode 100644 index 34065557bf..0000000000 --- a/src/client/components/media-banner.vue +++ /dev/null @@ -1,107 +0,0 @@ -<template> -<div class="mk-media-banner"> - <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false"> - <span class="icon"><i class="fas fa-exclamation-triangle"></i></span> - <b>{{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> - </div> - <div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'"> - <audio class="audio" - :src="media.url" - :title="media.name" - controls - ref="audio" - @volumechange="volumechange" - preload="metadata" /> - </div> - <a class="download" v-else - :href="media.url" - :title="media.name" - :download="media.name" - > - <span class="icon"><i class="fas fa-download"></i></span> - <b>{{ media.name }}</b> - </a> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; - -export default defineComponent({ - props: { - media: { - type: Object, - required: true - } - }, - data() { - return { - hide: true, - }; - }, - mounted() { - const audioTag = this.$refs.audio as HTMLAudioElement; - if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); - }, - methods: { - volumechange() { - const audioTag = this.$refs.audio as HTMLAudioElement; - ColdDeviceStorage.set('mediaVolume', audioTag.volume); - }, - }, -}) -</script> - -<style lang="scss" scoped> -.mk-media-banner { - width: 100%; - border-radius: 4px; - margin-top: 4px; - overflow: hidden; - - > .download, - > .sensitive { - display: flex; - align-items: center; - font-size: 12px; - padding: 8px 12px; - white-space: nowrap; - - > * { - display: block; - } - - > b { - overflow: hidden; - text-overflow: ellipsis; - } - - > *:not(:last-child) { - margin-right: .2em; - } - - > .icon { - font-size: 1.6em; - } - } - - > .download { - background: var(--noteAttachedFile); - } - - > .sensitive { - background: #111; - color: #fff; - } - - > .audio { - .audio { - display: block; - width: 100%; - } - } -} -</style> diff --git a/src/client/components/media-caption.vue b/src/client/components/media-caption.vue deleted file mode 100644 index b35b101d06..0000000000 --- a/src/client/components/media-caption.vue +++ /dev/null @@ -1,260 +0,0 @@ -<template> - <MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> - <div class="container"> - <div class="fullwidth top-caption"> - <div class="mk-dialog"> - <header> - <Mfm v-if="title" class="title" :text="title"/> - <span class="text-count" :class="{ over: remainingLength < 0 }">{{ remainingLength }}</span> - </header> - <textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea> - <div class="buttons" v-if="(showOkButton || showCancelButton)"> - <MkButton inline @click="ok" primary :disabled="remainingLength < 0">{{ $ts.ok }}</MkButton> - <MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton> - </div> - </div> - </div> - <div class="hdrwpsaf fullwidth"> - <header>{{ image.name }}</header> - <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> - <footer> - <span>{{ image.type }}</span> - <span>{{ bytes(image.size) }}</span> - <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> - </footer> - </div> - </div> - </MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { length } from 'stringz'; -import MkModal from '@client/components/ui/modal.vue'; -import MkButton from '@client/components/ui/button.vue'; -import bytes from '@client/filters/bytes'; -import number from '@client/filters/number'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits'; - -export default defineComponent({ - components: { - MkModal, - MkButton, - }, - - props: { - image: { - type: Object, - required: true, - }, - title: { - type: String, - required: false - }, - input: { - required: true - }, - showOkButton: { - type: Boolean, - default: true - }, - showCancelButton: { - type: Boolean, - default: true - }, - cancelableByBgClick: { - type: Boolean, - default: true - }, - }, - - emits: ['done', 'closed'], - - data() { - return { - inputValue: this.input.default ? this.input.default : null - }; - }, - - mounted() { - document.addEventListener('keydown', this.onKeydown); - }, - - beforeUnmount() { - document.removeEventListener('keydown', this.onKeydown); - }, - - computed: { - remainingLength(): number { - if (typeof this.inputValue != "string") return DB_MAX_IMAGE_COMMENT_LENGTH; - return DB_MAX_IMAGE_COMMENT_LENGTH - length(this.inputValue); - } - }, - - methods: { - bytes, - number, - - done(canceled, result?) { - this.$emit('done', { canceled, result }); - this.$refs.modal.close(); - }, - - async ok() { - if (!this.showOkButton) return; - - const result = this.inputValue; - this.done(false, result); - }, - - cancel() { - this.done(true); - }, - - onBgClick() { - if (this.cancelableByBgClick) { - this.cancel(); - } - }, - - onKeydown(e) { - if (e.which === 27) { // ESC - this.cancel(); - } - }, - - onInputKeydown(e) { - if (e.which === 13) { // Enter - if (e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - this.ok(); - } - } - } - } -}); -</script> - -<style lang="scss" scoped> -.container { - display: flex; - width: 100%; - height: 100%; - flex-direction: row; -} -@media (max-width: 850px) { - .container { - flex-direction: column; - } - .top-caption { - padding-bottom: 8px; - } -} -.fullwidth { - width: 100%; - margin: auto; -} -.mk-dialog { - position: relative; - padding: 32px; - min-width: 320px; - max-width: 480px; - box-sizing: border-box; - text-align: center; - background: var(--panel); - border-radius: var(--radius); - margin: auto; - - > header { - margin: 0 0 8px 0; - position: relative; - - > .title { - font-weight: bold; - font-size: 20px; - } - - > .text-count { - opacity: 0.7; - position: absolute; - right: 0; - } - } - - > .buttons { - margin-top: 16px; - - > * { - margin: 0 8px; - } - } - - > textarea { - display: block; - box-sizing: border-box; - padding: 0 24px; - margin: 0; - width: 100%; - font-size: 16px; - border: none; - border-radius: 0; - background: transparent; - color: var(--fg); - font-family: inherit; - max-width: 100%; - min-width: 100%; - min-height: 90px; - - &:focus-visible { - outline: none; - } - - &:disabled { - opacity: 0.5; - } - } -} -.hdrwpsaf { - display: flex; - flex-direction: column; - height: 100%; - - > header, - > footer { - align-self: center; - display: inline-block; - padding: 6px 9px; - font-size: 90%; - background: rgba(0, 0, 0, 0.5); - border-radius: 6px; - color: #fff; - } - - > header { - margin-bottom: 8px; - opacity: 0.9; - } - - > img { - display: block; - flex: 1; - min-height: 0; - object-fit: contain; - width: 100%; - cursor: zoom-out; - image-orientation: from-image; - } - - > footer { - margin-top: 8px; - opacity: 0.8; - - > span + span { - margin-left: 0.5em; - padding-left: 0.5em; - border-left: solid 1px rgba(255, 255, 255, 0.5); - } - } -} -</style> diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue deleted file mode 100644 index fd5e0b5f9b..0000000000 --- a/src/client/components/media-image.vue +++ /dev/null @@ -1,155 +0,0 @@ -<template> -<div class="qjewsnkg" v-if="hide" @click="hide = false"> - <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> - <div class="text"> - <div> - <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> - </div> - </div> -</div> -<div class="gqnyydlz" :style="{ background: color }" v-else> - <a - :href="image.url" - :title="image.name" - > - <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> - <div class="gif" v-if="image.type === 'image/gif'">GIF</div> - </a> - <i class="fas fa-eye-slash" @click="hide = true"></i> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import { extractAvgColorFromBlurhash } from '@client/scripts/extract-avg-color-from-blurhash'; -import ImageViewer from './image-viewer.vue'; -import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - ImgWithBlurhash - }, - props: { - image: { - type: Object, - required: true - }, - raw: { - default: false - } - }, - data() { - return { - hide: true, - color: null, - }; - }, - computed: { - url(): any { - let url = this.$store.state.disableShowingAnimatedImages - ? getStaticImageUrl(this.image.thumbnailUrl) - : this.image.thumbnailUrl; - - if (this.raw || this.$store.state.loadRawImages) { - url = this.image.url; - } - - return url; - } - }, - created() { - // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする - this.$watch('image', () => { - this.hide = (this.$store.state.nsfw === 'force') ? true : this.image.isSensitive && (this.$store.state.nsfw !== 'ignore'); - if (this.image.blurhash) { - this.color = extractAvgColorFromBlurhash(this.image.blurhash); - } - }, { - deep: true, - immediate: true, - }); - }, -}); -</script> - -<style lang="scss" scoped> -.qjewsnkg { - position: relative; - - > .bg { - filter: brightness(0.5); - } - - > .text { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - z-index: 1; - display: flex; - justify-content: center; - align-items: center; - - > div { - display: table-cell; - text-align: center; - font-size: 0.8em; - color: #fff; - - > * { - display: block; - } - } - } -} - -.gqnyydlz { - position: relative; - border: solid 0.5px var(--divider); - - > i { - display: block; - position: absolute; - border-radius: 6px; - background-color: var(--fg); - color: var(--accentLighten); - font-size: 14px; - opacity: .5; - padding: 3px 6px; - text-align: center; - cursor: pointer; - top: 12px; - right: 12px; - } - - > a { - display: block; - cursor: zoom-in; - overflow: hidden; - width: 100%; - height: 100%; - background-position: center; - background-size: contain; - background-repeat: no-repeat; - - > .gif { - background-color: var(--fg); - border-radius: 6px; - color: var(--accentLighten); - display: inline-block; - font-size: 14px; - font-weight: bold; - left: 12px; - opacity: .5; - padding: 0 6px; - text-align: center; - top: 12px; - pointer-events: none; - } - } -} -</style> diff --git a/src/client/components/media-list.vue b/src/client/components/media-list.vue deleted file mode 100644 index c499525d84..0000000000 --- a/src/client/components/media-list.vue +++ /dev/null @@ -1,167 +0,0 @@ -<template> -<div class="hoawjimk"> - <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :media="media" :key="media.id"/> - <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container"> - <div :data-count="mediaList.filter(media => previewable(media)).length" ref="gallery"> - <template v-for="media in mediaList"> - <XVideo :video="media" :key="media.id" v-if="media.type.startsWith('video')"/> - <XImage class="image" :data-id="media.id" :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/> - </template> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, PropType, ref } from 'vue'; -import * as misskey from 'misskey-js'; -import PhotoSwipeLightbox from 'photoswipe/dist/photoswipe-lightbox.esm.js'; -import PhotoSwipe from 'photoswipe/dist/photoswipe.esm.js'; -import 'photoswipe/dist/photoswipe.css'; -import XBanner from './media-banner.vue'; -import XImage from './media-image.vue'; -import XVideo from './media-video.vue'; -import * as os from '@client/os'; -import { defaultStore } from '@client/store'; - -export default defineComponent({ - components: { - XBanner, - XImage, - XVideo, - }, - props: { - mediaList: { - type: Array as PropType<misskey.entities.DriveFile[]>, - required: true, - }, - raw: { - default: false - }, - }, - setup(props) { - const gallery = ref(null); - - onMounted(() => { - const lightbox = new PhotoSwipeLightbox({ - dataSource: props.mediaList.filter(media => media.type.startsWith('image')).map(media => ({ - src: media.url, - w: media.properties.width, - h: media.properties.height, - alt: media.name, - })), - gallery: gallery.value, - children: '.image', - thumbSelector: '.image', - pswpModule: PhotoSwipe - }); - - lightbox.on('itemData', (e) => { - const { itemData } = e; - - // element is children - const { element } = itemData; - - const id = element.dataset.id; - const file = props.mediaList.find(media => media.id === id); - - itemData.src = file.url; - itemData.w = Number(file.properties.width); - itemData.h = Number(file.properties.height); - itemData.msrc = file.thumbnailUrl; - itemData.thumbCropped = true; - }); - - lightbox.init(); - }); - - const previewable = (file: misskey.entities.DriveFile): boolean => { - return file.type.startsWith('video') || file.type.startsWith('image'); - }; - - return { - previewable, - gallery, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.hoawjimk { - > .gird-container { - position: relative; - width: 100%; - margin-top: 4px; - - &:before { - content: ''; - display: block; - padding-top: 56.25% // 16:9; - } - - > div { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: grid; - grid-gap: 4px; - - > * { - overflow: hidden; - border-radius: 6px; - } - - &[data-count="1"] { - grid-template-rows: 1fr; - } - - &[data-count="2"] { - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr; - } - - &[data-count="3"] { - grid-template-columns: 1fr 0.5fr; - grid-template-rows: 1fr 1fr; - - > *:nth-child(1) { - grid-row: 1 / 3; - } - - > *:nth-child(3) { - grid-column: 2 / 3; - grid-row: 2 / 3; - } - } - - &[data-count="4"] { - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr 1fr; - } - - > *:nth-child(1) { - grid-column: 1 / 2; - grid-row: 1 / 2; - } - - > *:nth-child(2) { - grid-column: 2 / 3; - grid-row: 1 / 2; - } - - > *:nth-child(3) { - grid-column: 1 / 2; - grid-row: 2 / 3; - } - - > *:nth-child(4) { - grid-column: 2 / 3; - grid-row: 2 / 3; - } - } - } -} -</style> diff --git a/src/client/components/media-video.vue b/src/client/components/media-video.vue deleted file mode 100644 index 4d4a551653..0000000000 --- a/src/client/components/media-video.vue +++ /dev/null @@ -1,97 +0,0 @@ -<template> -<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="hide" @click="hide = false"> - <div> - <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> - </div> -</div> -<div class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else> - <video - :poster="video.thumbnailUrl" - :title="video.name" - preload="none" - controls - @contextmenu.stop - > - <source - :src="video.url" - :type="video.type" - > - </video> - <i class="fas fa-eye-slash" @click="hide = true"></i> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - video: { - type: Object, - required: true - } - }, - data() { - return { - hide: true, - }; - }, - created() { - this.hide = (this.$store.state.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.nsfw !== 'ignore'); - }, -}); -</script> - -<style lang="scss" scoped> -.kkjnbbplepmiyuadieoenjgutgcmtsvu { - position: relative; - - > i { - display: block; - position: absolute; - border-radius: 6px; - background-color: var(--fg); - color: var(--accentLighten); - font-size: 14px; - opacity: .5; - padding: 3px 6px; - text-align: center; - cursor: pointer; - top: 12px; - right: 12px; - } - - > video { - display: flex; - justify-content: center; - align-items: center; - - font-size: 3.5em; - overflow: hidden; - background-position: center; - background-size: cover; - width: 100%; - height: 100%; - } -} - -.icozogqfvdetwohsdglrbswgrejoxbdj { - display: flex; - justify-content: center; - align-items: center; - background: #111; - color: #fff; - - > div { - display: table-cell; - text-align: center; - font-size: 12px; - - > b { - display: block; - } - } -} -</style> diff --git a/src/client/components/mention.vue b/src/client/components/mention.vue deleted file mode 100644 index 4c7030bf35..0000000000 --- a/src/client/components/mention.vue +++ /dev/null @@ -1,86 +0,0 @@ -<template> -<MkA v-if="url.startsWith('/')" class="ldlomzub" :class="{ isMe }" :to="url" v-user-preview="canonical" :style="{ background: bg }"> - <img class="icon" :src="`/avatar/@${username}@${host}`" alt=""> - <span class="main"> - <span class="username">@{{ username }}</span> - <span class="host" v-if="(host != localHost) || $store.state.showFullAcct">@{{ toUnicode(host) }}</span> - </span> -</MkA> -<a v-else class="ldlomzub" :href="url" target="_blank" rel="noopener" :style="{ background: bg }"> - <span class="main"> - <span class="username">@{{ username }}</span> - <span class="host">@{{ toUnicode(host) }}</span> - </span> -</a> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as tinycolor from 'tinycolor2'; -import { toUnicode } from 'punycode'; -import { host as localHost } from '@client/config'; -import { wellKnownServices } from '../../well-known-services'; -import { $i } from '@client/account'; - -export default defineComponent({ - props: { - username: { - type: String, - required: true - }, - host: { - type: String, - required: true - } - }, - - setup(props) { - const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; - - const wellKnown = wellKnownServices.find(x => x[0] === props.host); - const url = wellKnown ? wellKnown[1](props.username) : `/${canonical}`; - - const isMe = $i && ( - `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() - ); - - const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); - bg.setAlpha(0.1); - - return { - localHost, - isMe, - url, - canonical, - toUnicode, - bg: bg.toRgbString(), - }; - }, -}); -</script> - -<style lang="scss" scoped> -.ldlomzub { - display: inline-block; - padding: 4px 8px 4px 4px; - border-radius: 999px; - color: var(--mention); - - &.isMe { - color: var(--mentionMe); - } - - > .icon { - width: 1.5em; - margin: 0 0.2em 0 0; - vertical-align: bottom; - border-radius: 100%; - } - - > .main { - > .host { - opacity: 0.5; - } - } -} -</style> diff --git a/src/client/components/mfm.ts b/src/client/components/mfm.ts deleted file mode 100644 index ad6e711f6f..0000000000 --- a/src/client/components/mfm.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { VNode, defineComponent, h } from 'vue'; -import * as mfm from 'mfm-js'; -import MkUrl from '@client/components/global/url.vue'; -import MkLink from '@client/components/link.vue'; -import MkMention from '@client/components/mention.vue'; -import MkEmoji from '@client/components/global/emoji.vue'; -import { concat } from '@client/../prelude/array'; -import MkFormula from '@client/components/formula.vue'; -import MkCode from '@client/components/code.vue'; -import MkGoogle from '@client/components/google.vue'; -import MkSparkle from '@client/components/sparkle.vue'; -import MkA from '@client/components/global/a.vue'; -import { host } from '@client/config'; -import { fnNameList } from '@/mfm/fn-name-list'; - -export default defineComponent({ - props: { - text: { - type: String, - required: true - }, - plain: { - type: Boolean, - default: false - }, - nowrap: { - type: Boolean, - default: false - }, - author: { - type: Object, - default: null - }, - i: { - type: Object, - default: null - }, - customEmojis: { - required: false, - }, - isNote: { - type: Boolean, - default: true - }, - }, - - render() { - if (this.text == null || this.text == '') return; - - const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text, { fnNameList }); - - const validTime = (t: string | null | undefined) => { - if (t == null) return null; - return t.match(/^[0-9.]+s$/) ? t : null; - }; - - const genEl = (ast: mfm.MfmNode[]) => concat(ast.map((token): VNode[] => { - switch (token.type) { - case 'text': { - const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); - - if (!this.plain) { - const res = []; - for (const t of text.split('\n')) { - res.push(h('br')); - res.push(t); - } - res.shift(); - return res; - } else { - return [text.replace(/\n/g, ' ')]; - } - } - - case 'bold': { - return [h('b', genEl(token.children))]; - } - - case 'strike': { - return [h('del', genEl(token.children))]; - } - - case 'italic': { - return h('i', { - style: 'font-style: oblique;' - }, genEl(token.children)); - } - - case 'fn': { - // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる - let style; - switch (token.props.name) { - case 'tada': { - style = `font-size: 150%;` + (this.$store.state.animatedMfm ? 'animation: tada 1s linear infinite both;' : ''); - break; - } - case 'jelly': { - const speed = validTime(token.props.args.speed) || '1s'; - style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); - break; - } - case 'twitch': { - const speed = validTime(token.props.args.speed) || '0.5s'; - style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : ''; - break; - } - case 'shake': { - const speed = validTime(token.props.args.speed) || '0.5s'; - style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : ''; - break; - } - case 'spin': { - const direction = - token.props.args.left ? 'reverse' : - token.props.args.alternate ? 'alternate' : - 'normal'; - const anime = - token.props.args.x ? 'mfm-spinX' : - token.props.args.y ? 'mfm-spinY' : - 'mfm-spin'; - const speed = validTime(token.props.args.speed) || '1.5s'; - style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; - break; - } - case 'jump': { - style = this.$store.state.animatedMfm ? 'animation: mfm-jump 0.75s linear infinite;' : ''; - break; - } - case 'bounce': { - style = this.$store.state.animatedMfm ? 'animation: mfm-bounce 0.75s linear infinite; transform-origin: center bottom;' : ''; - break; - } - case 'flip': { - const transform = - (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : - token.props.args.v ? 'scaleY(-1)' : - 'scaleX(-1)'; - style = `transform: ${transform};`; - break; - } - case 'x2': { - style = `font-size: 200%;`; - break; - } - case 'x3': { - style = `font-size: 400%;`; - break; - } - case 'x4': { - style = `font-size: 600%;`; - break; - } - case 'font': { - const family = - token.props.args.serif ? 'serif' : - token.props.args.monospace ? 'monospace' : - token.props.args.cursive ? 'cursive' : - token.props.args.fantasy ? 'fantasy' : - token.props.args.emoji ? 'emoji' : - token.props.args.math ? 'math' : - null; - if (family) style = `font-family: ${family};`; - break; - } - case 'blur': { - return h('span', { - class: '_mfm_blur_', - }, genEl(token.children)); - } - case 'rainbow': { - style = this.$store.state.animatedMfm ? 'animation: mfm-rainbow 1s linear infinite;' : ''; - break; - } - case 'sparkle': { - if (!this.$store.state.animatedMfm) { - return genEl(token.children); - } - let count = token.props.args.count ? parseInt(token.props.args.count) : 10; - if (count > 100) { - count = 100; - } - const speed = token.props.args.speed ? parseFloat(token.props.args.speed) : 1; - return h(MkSparkle, { - count, speed, - }, genEl(token.children)); - } - } - if (style == null) { - return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); - } else { - return h('span', { - style: 'display: inline-block;' + style, - }, genEl(token.children)); - } - } - - case 'small': { - return [h('small', { - style: 'opacity: 0.7;' - }, genEl(token.children))]; - } - - case 'center': { - return [h('div', { - style: 'text-align:center;' - }, genEl(token.children))]; - } - - case 'url': { - return [h(MkUrl, { - key: Math.random(), - url: token.props.url, - rel: 'nofollow noopener', - })]; - } - - case 'link': { - return [h(MkLink, { - key: Math.random(), - url: token.props.url, - rel: 'nofollow noopener', - }, genEl(token.children))]; - } - - case 'mention': { - return [h(MkMention, { - key: Math.random(), - host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, - username: token.props.username - })]; - } - - case 'hashtag': { - return [h(MkA, { - key: Math.random(), - to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, - style: 'color:var(--hashtag);' - }, `#${token.props.hashtag}`)]; - } - - case 'blockCode': { - return [h(MkCode, { - key: Math.random(), - code: token.props.code, - lang: token.props.lang, - })]; - } - - case 'inlineCode': { - return [h(MkCode, { - key: Math.random(), - code: token.props.code, - inline: true - })]; - } - - case 'quote': { - if (!this.nowrap) { - return [h('div', { - class: 'quote' - }, genEl(token.children))]; - } else { - return [h('span', { - class: 'quote' - }, genEl(token.children))]; - } - } - - case 'emojiCode': { - return [h(MkEmoji, { - key: Math.random(), - emoji: `:${token.props.name}:`, - customEmojis: this.customEmojis, - normal: this.plain - })]; - } - - case 'unicodeEmoji': { - return [h(MkEmoji, { - key: Math.random(), - emoji: token.props.emoji, - customEmojis: this.customEmojis, - normal: this.plain - })]; - } - - case 'mathInline': { - return [h(MkFormula, { - key: Math.random(), - formula: token.props.formula, - block: false - })]; - } - - case 'mathBlock': { - return [h(MkFormula, { - key: Math.random(), - formula: token.props.formula, - block: true - })]; - } - - case 'search': { - return [h(MkGoogle, { - key: Math.random(), - q: token.props.query - })]; - } - - default: { - console.error('unrecognized ast type:', token.type); - - return []; - } - } - })); - - // Parse ast to DOM - return h('span', genEl(ast)); - } -}); diff --git a/src/client/components/mini-chart.vue b/src/client/components/mini-chart.vue deleted file mode 100644 index 0d01e4e4b5..0000000000 --- a/src/client/components/mini-chart.vue +++ /dev/null @@ -1,90 +0,0 @@ -<template> -<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible"> - <defs> - <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> - </linearGradient> - <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="polygonPoints" - fill="#fff" - fill-opacity="0.5"/> - <polyline - :points="polylinePoints" - fill="none" - stroke="#fff" - stroke-width="2"/> - <circle - :cx="headX" - :cy="headY" - r="3" - fill="#fff"/> - </mask> - </defs> - <rect - x="-10" y="-10" - :width="viewBoxX + 20" :height="viewBoxY + 20" - :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/> -</svg> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { v4 as uuid } from 'uuid'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - src: { - type: Array, - required: true - } - }, - data() { - return { - viewBoxX: 50, - viewBoxY: 30, - gradientId: uuid(), - maskId: uuid(), - polylinePoints: '', - polygonPoints: '', - headX: null, - headY: null, - clock: null - }; - }, - watch: { - src() { - this.draw(); - } - }, - created() { - this.draw(); - - // Vueが何故かWatchを発動させない場合があるので - this.clock = setInterval(this.draw, 1000); - }, - beforeUnmount() { - clearInterval(this.clock); - }, - methods: { - draw() { - const stats = this.src.slice().reverse(); - const peak = Math.max.apply(null, stats) || 1; - - const polylinePoints = stats.map((n, i) => [ - i * (this.viewBoxX / (stats.length - 1)), - (1 - (n / peak)) * this.viewBoxY - ]); - - this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - - this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; - - this.headX = polylinePoints[polylinePoints.length - 1][0]; - this.headY = polylinePoints[polylinePoints.length - 1][1]; - } - } -}); -</script> diff --git a/src/client/components/modal-page-window.vue b/src/client/components/modal-page-window.vue deleted file mode 100644 index e47d3dc62c..0000000000 --- a/src/client/components/modal-page-window.vue +++ /dev/null @@ -1,223 +0,0 @@ -<template> -<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> - <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> - <div class="header" @contextmenu="onContextmenu"> - <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> - <span v-else style="display: inline-block; width: 20px"></span> - <span v-if="pageInfo" class="title"> - <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i> - <span>{{ pageInfo.title }}</span> - </span> - <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button> - </div> - <div class="body"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <keep-alive> - <component :is="component" v-bind="props" :ref="changePage"/> - </keep-alive> - </MkStickyContainer> - </div> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from '@client/components/ui/modal.vue'; -import { popout } from '@client/scripts/popout'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/symbols'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkModal, - }, - - inject: { - sideViewHook: { - default: null - } - }, - - provide() { - return { - navHook: (path) => { - this.navigate(path); - }, - shouldHeaderThin: true, - }; - }, - - props: { - initialPath: { - type: String, - required: true, - }, - initialComponent: { - type: Object, - required: true, - }, - initialProps: { - type: Object, - required: false, - default: () => {}, - }, - }, - - emits: ['closed'], - - data() { - return { - width: 860, - height: 660, - pageInfo: null, - path: this.initialPath, - component: this.initialComponent, - props: this.initialProps, - history: [], - }; - }, - - computed: { - url(): string { - return url + this.path; - }, - - contextmenu() { - return [{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: this.expand - }, this.sideViewHook ? { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.sideViewHook(this.path); - this.$refs.window.close(); - } - } : undefined, { - icon: 'fas fa-external-link-alt', - text: this.$ts.popout, - action: this.popout - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }]; - }, - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - navigate(path, record = true) { - if (record) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - }, - - back() { - this.navigate(this.history.pop(), false); - }, - - expand() { - this.$router.push(this.path); - this.$refs.window.close(); - }, - - popout() { - popout(this.path, this.$el); - this.$refs.window.close(); - }, - - onContextmenu(e) { - os.contextMenu(this.contextmenu, e); - } - }, -}); -</script> - -<style lang="scss" scoped> -.hrmcaedk { - overflow: hidden; - display: flex; - flex-direction: column; - contain: content; - - --root-margin: 24px; - - @media (max-width: 500px) { - --root-margin: 16px; - } - - > .header { - $height: 52px; - $height-narrow: 42px; - display: flex; - flex-shrink: 0; - height: $height; - line-height: $height; - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - box-shadow: 0px 1px var(--divider); - - > button { - height: $height; - width: $height; - - &:hover { - color: var(--fgHighlighted); - } - } - - @media (max-width: 500px) { - height: $height-narrow; - line-height: $height-narrow; - padding-left: 16px; - - > button { - height: $height-narrow; - width: $height-narrow; - } - } - - > .title { - flex: 1; - - > .icon { - margin-right: 0.5em; - } - } - } - - > .body { - overflow: auto; - background: var(--bg); - } -} -</style> diff --git a/src/client/components/note-detailed.vue b/src/client/components/note-detailed.vue deleted file mode 100644 index 568a2360d1..0000000000 --- a/src/client/components/note-detailed.vue +++ /dev/null @@ -1,1229 +0,0 @@ -<template> -<div - class="lxwezrsl _block" - v-if="!muted" - v-show="!isDeleted" - :tabindex="!isDeleted ? '-1' : null" - :class="{ renote: isRenote }" - v-hotkey="keymap" - v-size="{ max: [500, 450, 350, 300] }" -> - <XSub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/> - <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> - <div class="renote" v-if="isRenote"> - <MkAvatar class="avatar" :user="note.user"/> - <i class="fas fa-retweet"></i> - <I18n :src="$ts.renotedBy" tag="span"> - <template #user> - <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> - <MkUserName :user="note.user"/> - </MkA> - </template> - </I18n> - <div class="info"> - <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> - <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> - <MkTime :time="note.createdAt"/> - </button> - <span class="visibility" v-if="note.visibility !== 'public'"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> - </div> - </div> - <article class="article" @contextmenu.stop="onContextmenu"> - <header class="header"> - <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/> - <div class="body"> - <div class="top"> - <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.user.id"> - <MkUserName :user="appearNote.user"/> - </MkA> - <span class="is-bot" v-if="appearNote.user.isBot">bot</span> - <span class="admin" v-if="appearNote.user.isAdmin"><i class="fas fa-bookmark"></i></span> - <span class="moderator" v-if="!appearNote.user.isAdmin && appearNote.user.isModerator"><i class="far fa-bookmark"></i></span> - <span class="visibility" v-if="appearNote.visibility !== 'public'"> - <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span class="localOnly" v-if="appearNote.localOnly"><i class="fas fa-biohazard"></i></span> - </div> - <div class="username"><MkAcct :user="appearNote.user"/></div> - <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> - </div> - </header> - <div class="main"> - <div class="body"> - <p v-if="appearNote.cw != null" class="cw"> - <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <XCwButton v-model="showContent" :note="appearNote"/> - </p> - <div class="content" v-show="appearNote.cw == null || showContent"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> - <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <a class="rp" v-if="appearNote.renote != null">RN:</a> - <div class="translation" v-if="translating || translation"> - <MkLoading v-if="translating" mini/> - <div class="translated" v-else> - <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b> - {{ translation.text }} - </div> - </div> - </div> - <div class="files" v-if="appearNote.files.length > 0"> - <XMediaList :media-list="appearNote.files"/> - </div> - <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> - <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="true" class="url-preview"/> - <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> - </div> - <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> - </div> - <footer class="footer"> - <div class="info"> - <span class="mobile" v-if="appearNote.viaMobile"><i class="fas fa-mobile-alt"></i></span> - <MkTime class="created-at" :time="appearNote.createdAt" mode="detail"/> - </div> - <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> - <button @click="reply()" class="button _button"> - <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> - <template v-else><i class="fas fa-reply"></i></template> - <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> - </button> - <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> - <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> - </button> - <button v-else class="button _button"> - <i class="fas fa-ban"></i> - </button> - <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> - <i class="fas fa-plus"></i> - </button> - <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> - <i class="fas fa-minus"></i> - </button> - <button class="button _button" @click="menu()" ref="menuButton"> - <i class="fas fa-ellipsis-h"></i> - </button> - </footer> - </div> - </article> - <XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> -</div> -<div v-else class="_panel muted" @click="muted = false"> - <I18n :src="$ts.userSaysSomething" tag="small"> - <template #name> - <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> - <MkUserName :user="appearNote.user"/> - </MkA> - </template> - </I18n> -</div> -</template> - -<script lang="ts"> -import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; -import * as mfm from 'mfm-js'; -import { sum } from '../../prelude/array'; -import XSub from './note.sub.vue'; -import XNoteHeader from './note-header.vue'; -import XNoteSimple from './note-simple.vue'; -import XReactionsViewer from './reactions-viewer.vue'; -import XMediaList from './media-list.vue'; -import XCwButton from './cw-button.vue'; -import XPoll from './poll.vue'; -import { pleaseLogin } from '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -// TODO: note.vueとほぼ同じなので共通化したい -export default defineComponent({ - components: { - XSub, - XNoteHeader, - XNoteSimple, - XReactionsViewer, - XMediaList, - XCwButton, - XPoll, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), - }, - - inject: { - inChannel: { - default: null - }, - }, - - props: { - note: { - type: Object, - required: true - }, - }, - - emits: ['update:note'], - - data() { - return { - connection: null, - conversation: [], - replies: [], - showContent: false, - isDeleted: false, - muted: false, - translation: null, - translating: false, - }; - }, - - computed: { - rs() { - return this.$store.state.reactions; - }, - keymap(): any { - return { - 'r': () => this.reply(true), - 'e|a|plus': () => this.react(true), - 'q': () => this.renote(true), - 'f|b': this.favorite, - 'delete|ctrl+d': this.del, - 'ctrl+q': this.renoteDirectly, - 'up|k|shift+tab': this.focusBefore, - 'down|j|tab': this.focusAfter, - 'esc': this.blur, - 'm|o': () => this.menu(true), - 's': this.toggleShowContent, - '1': () => this.reactDirectly(this.rs[0]), - '2': () => this.reactDirectly(this.rs[1]), - '3': () => this.reactDirectly(this.rs[2]), - '4': () => this.reactDirectly(this.rs[3]), - '5': () => this.reactDirectly(this.rs[4]), - '6': () => this.reactDirectly(this.rs[5]), - '7': () => this.reactDirectly(this.rs[6]), - '8': () => this.reactDirectly(this.rs[7]), - '9': () => this.reactDirectly(this.rs[8]), - '0': () => this.reactDirectly(this.rs[9]), - }; - }, - - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - this.note.fileIds.length == 0 && - this.note.poll == null); - }, - - appearNote(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - isMyNote(): boolean { - return this.$i && (this.$i.id === this.appearNote.userId); - }, - - isMyRenote(): boolean { - return this.$i && (this.$i.id === this.note.userId); - }, - - canRenote(): boolean { - return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; - }, - - reactionsCount(): number { - return this.appearNote.reactions - ? sum(Object.values(this.appearNote.reactions)) - : 0; - }, - - urls(): string[] { - if (this.appearNote.text) { - return extractUrlFromMfm(mfm.parse(this.appearNote.text)); - } else { - return null; - } - }, - - showTicker() { - if (this.$store.state.instanceTicker === 'always') return true; - if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; - return false; - } - }, - - async created() { - if (this.$i) { - this.connection = os.stream; - } - - this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); - - // plugin - if (noteViewInterruptors.length > 0) { - let result = this.note; - for (const interruptor of noteViewInterruptors) { - result = await interruptor.handler(JSON.parse(JSON.stringify(result))); - } - this.$emit('update:note', Object.freeze(result)); - } - - os.api('notes/children', { - noteId: this.appearNote.id, - limit: 30 - }).then(replies => { - this.replies = replies; - }); - - if (this.appearNote.replyId) { - os.api('notes/conversation', { - noteId: this.appearNote.replyId - }).then(conversation => { - this.conversation = conversation.reverse(); - }); - } - }, - - mounted() { - this.capture(true); - - if (this.$i) { - this.connection.on('_connected_', this.onStreamConnected); - } - }, - - beforeUnmount() { - this.decapture(true); - - if (this.$i) { - this.connection.off('_connected_', this.onStreamConnected); - } - }, - - methods: { - updateAppearNote(v) { - this.$emit('update:note', Object.freeze(this.isRenote ? { - ...this.note, - renote: { - ...this.note.renote, - ...v - } - } : { - ...this.note, - ...v - })); - }, - - readPromo() { - os.api('promo/read', { - noteId: this.appearNote.id - }); - this.isDeleted = true; - }, - - capture(withHandler = false) { - if (this.$i) { - // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); - if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); - } - }, - - decapture(withHandler = false) { - if (this.$i) { - this.connection.send('un', { - id: this.appearNote.id - }); - if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); - } - }, - - onStreamConnected() { - this.capture(); - }, - - onStreamNoteUpdated(data) { - const { type, id, body } = data; - - if (id !== this.appearNote.id) return; - - switch (type) { - case 'reacted': { - const reaction = body.reaction; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - if (body.emoji) { - const emojis = this.appearNote.emojis || []; - if (!emojis.includes(body.emoji)) { - n.emojis = [...emojis, body.emoji]; - } - } - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (this.appearNote.reactions || {})[reaction] || 0; - - // Increment the count - n.reactions = { - ...this.appearNote.reactions, - [reaction]: currentCount + 1 - }; - - if (body.userId === this.$i.id) { - n.myReaction = reaction; - } - - this.updateAppearNote(n); - break; - } - - case 'unreacted': { - const reaction = body.reaction; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (this.appearNote.reactions || {})[reaction] || 0; - - // Decrement the count - n.reactions = { - ...this.appearNote.reactions, - [reaction]: Math.max(0, currentCount - 1) - }; - - if (body.userId === this.$i.id) { - n.myReaction = null; - } - - this.updateAppearNote(n); - break; - } - - case 'pollVoted': { - const choice = body.choice; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - const choices = [...this.appearNote.poll.choices]; - choices[choice] = { - ...choices[choice], - votes: choices[choice].votes + 1, - ...(body.userId === this.$i.id ? { - isVoted: true - } : {}) - }; - - n.poll = { - ...this.appearNote.poll, - choices: choices - }; - - this.updateAppearNote(n); - break; - } - - case 'deleted': { - this.isDeleted = true; - break; - } - } - }, - - reply(viaKeyboard = false) { - pleaseLogin(); - os.post({ - reply: this.appearNote, - animation: !viaKeyboard, - }, () => { - this.focus(); - }); - }, - - renote(viaKeyboard = false) { - pleaseLogin(); - this.blur(); - os.popupMenu([{ - text: this.$ts.renote, - icon: 'fas fa-retweet', - action: () => { - os.api('notes/create', { - renoteId: this.appearNote.id - }); - } - }, { - text: this.$ts.quote, - icon: 'fas fa-quote-right', - action: () => { - os.post({ - renote: this.appearNote, - }); - } - }], this.$refs.renoteButton, { - viaKeyboard - }); - }, - - renoteDirectly() { - os.apiWithDialog('notes/create', { - renoteId: this.appearNote.id - }, undefined, (res: any) => { - os.dialog({ - type: 'success', - text: this.$ts.renoted, - }); - }, (e: Error) => { - if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { - os.dialog({ - type: 'error', - text: this.$ts.cantRenote, - }); - } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { - os.dialog({ - type: 'error', - text: this.$ts.cantReRenote, - }); - } - }); - }, - - react(viaKeyboard = false) { - pleaseLogin(); - this.blur(); - reactionPicker.show(this.$refs.reactButton, reaction => { - os.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - }, () => { - this.focus(); - }); - }, - - reactDirectly(reaction) { - os.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - }, - - undoReact(note) { - const oldReaction = note.myReaction; - if (!oldReaction) return; - os.api('notes/reactions/delete', { - noteId: note.id - }); - }, - - favorite() { - pleaseLogin(); - os.apiWithDialog('notes/favorites/create', { - noteId: this.appearNote.id - }, undefined, (res: any) => { - os.dialog({ - type: 'success', - text: this.$ts.favorited, - }); - }, (e: Error) => { - if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { - os.dialog({ - type: 'error', - text: this.$ts.alreadyFavorited, - }); - } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { - os.dialog({ - type: 'error', - text: this.$ts.cantFavorite, - }); - } - }); - }, - - del() { - os.dialog({ - type: 'warning', - text: this.$ts.noteDeleteConfirm, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - os.api('notes/delete', { - noteId: this.appearNote.id - }); - }); - }, - - delEdit() { - os.dialog({ - type: 'warning', - text: this.$ts.deleteAndEditConfirm, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - os.api('notes/delete', { - noteId: this.appearNote.id - }); - - os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); - }); - }, - - toggleFavorite(favorite: boolean) { - os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { - noteId: this.appearNote.id - }); - }, - - toggleWatch(watch: boolean) { - os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { - noteId: this.appearNote.id - }); - }, - - toggleThreadMute(mute: boolean) { - os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { - noteId: this.appearNote.id - }); - }, - - getMenu() { - let menu; - if (this.$i) { - const statePromise = os.api('notes/state', { - noteId: this.appearNote.id - }); - - menu = [{ - icon: 'fas fa-copy', - text: this.$ts.copyContent, - action: this.copyContent - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: this.copyLink - }, (this.appearNote.url || this.appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: this.$ts.showOnRemote, - action: () => { - window.open(this.appearNote.url || this.appearNote.uri, '_blank'); - } - } : undefined, - { - icon: 'fas fa-share-alt', - text: this.$ts.share, - action: this.share - }, - this.$instance.translatorAvailable ? { - icon: 'fas fa-language', - text: this.$ts.translate, - action: this.translate - } : undefined, - null, - statePromise.then(state => state.isFavorited ? { - icon: 'fas fa-star', - text: this.$ts.unfavorite, - action: () => this.toggleFavorite(false) - } : { - icon: 'fas fa-star', - text: this.$ts.favorite, - action: () => this.toggleFavorite(true) - }), - { - icon: 'fas fa-paperclip', - text: this.$ts.clip, - action: () => this.clip() - }, - (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { - icon: 'fas fa-eye-slash', - text: this.$ts.unwatch, - action: () => this.toggleWatch(false) - } : { - icon: 'fas fa-eye', - text: this.$ts.watch, - action: () => this.toggleWatch(true) - }) : undefined, - statePromise.then(state => state.isMutedThread ? { - icon: 'fas fa-comment-slash', - text: this.$ts.unmuteThread, - action: () => this.toggleThreadMute(false) - } : { - icon: 'fas fa-comment-slash', - text: this.$ts.muteThread, - action: () => this.toggleThreadMute(true) - }), - this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { - icon: 'fas fa-thumbtack', - text: this.$ts.unpin, - action: () => this.togglePin(false) - } : { - icon: 'fas fa-thumbtack', - text: this.$ts.pin, - action: () => this.togglePin(true) - } : undefined, - ...(this.$i.isModerator || this.$i.isAdmin ? [ - null, - { - icon: 'fas fa-bullhorn', - text: this.$ts.promote, - action: this.promote - }] - : [] - ), - ...(this.appearNote.userId != this.$i.id ? [ - null, - { - icon: 'fas fa-exclamation-circle', - text: this.$ts.reportAbuse, - action: () => { - const u = `${url}/notes/${this.appearNote.id}`; - os.popup(import('@client/components/abuse-report-window.vue'), { - user: this.appearNote.user, - initialComment: `Note: ${u}\n-----\n` - }, {}, 'closed'); - } - }] - : [] - ), - ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ - null, - this.appearNote.userId == this.$i.id ? { - icon: 'fas fa-edit', - text: this.$ts.deleteAndEdit, - action: this.delEdit - } : undefined, - { - icon: 'fas fa-trash-alt', - text: this.$ts.delete, - danger: true, - action: this.del - }] - : [] - )] - .filter(x => x !== undefined); - } else { - menu = [{ - icon: 'fas fa-copy', - text: this.$ts.copyContent, - action: this.copyContent - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: this.copyLink - }, (this.appearNote.url || this.appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: this.$ts.showOnRemote, - action: () => { - window.open(this.appearNote.url || this.appearNote.uri, '_blank'); - } - } : undefined] - .filter(x => x !== undefined); - } - - if (noteActions.length > 0) { - menu = menu.concat([null, ...noteActions.map(action => ({ - icon: 'fas fa-plug', - text: action.title, - action: () => { - action.handler(this.appearNote); - } - }))]); - } - - return menu; - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (window.getSelection().toString() !== '') return; - - if (this.$store.state.useReactionPickerForContextMenu) { - e.preventDefault(); - this.react(); - } else { - os.contextMenu(this.getMenu(), e).then(this.focus); - } - }, - - menu(viaKeyboard = false) { - os.popupMenu(this.getMenu(), this.$refs.menuButton, { - viaKeyboard - }).then(this.focus); - }, - - showRenoteMenu(viaKeyboard = false) { - if (!this.isMyRenote) return; - os.popupMenu([{ - text: this.$ts.unrenote, - icon: 'fas fa-trash-alt', - danger: true, - action: () => { - os.api('notes/delete', { - noteId: this.note.id - }); - this.isDeleted = true; - } - }], this.$refs.renoteTime, { - viaKeyboard: viaKeyboard - }); - }, - - toggleShowContent() { - this.showContent = !this.showContent; - }, - - copyContent() { - copyToClipboard(this.appearNote.text); - os.success(); - }, - - copyLink() { - copyToClipboard(`${url}/notes/${this.appearNote.id}`); - os.success(); - }, - - togglePin(pin: boolean) { - os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { - noteId: this.appearNote.id - }, undefined, null, e => { - if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { - os.dialog({ - type: 'error', - text: this.$ts.pinLimitExceeded - }); - } - }); - }, - - async clip() { - const clips = await os.api('clips/list'); - os.popupMenu([{ - icon: 'fas fa-plus', - text: this.$ts.createNew, - action: async () => { - const { canceled, result } = await os.form(this.$ts.createNewClip, { - name: { - type: 'string', - label: this.$ts.name - }, - description: { - type: 'string', - required: false, - multiline: true, - label: this.$ts.description - }, - isPublic: { - type: 'boolean', - label: this.$ts.public, - default: false - } - }); - if (canceled) return; - - const clip = await os.apiWithDialog('clips/create', result); - - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); - } - }, null, ...clips.map(clip => ({ - text: clip.name, - action: () => { - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); - } - }))], this.$refs.menuButton, { - }).then(this.focus); - }, - - async promote() { - const { canceled, result: days } = await os.dialog({ - title: this.$ts.numberOfDays, - input: { type: 'number' } - }); - - if (canceled) return; - - os.apiWithDialog('admin/promo/create', { - noteId: this.appearNote.id, - expiresAt: Date.now() + (86400000 * days) - }); - }, - - share() { - navigator.share({ - title: this.$t('noteOf', { user: this.appearNote.user.name }), - text: this.appearNote.text, - url: `${url}/notes/${this.appearNote.id}` - }); - }, - - async translate() { - if (this.translation != null) return; - this.translating = true; - const res = await os.api('notes/translate', { - noteId: this.appearNote.id, - targetLang: localStorage.getItem('lang') || navigator.language, - }); - this.translating = false; - this.translation = res; - }, - - focus() { - this.$el.focus(); - }, - - blur() { - this.$el.blur(); - }, - - focusBefore() { - focusPrev(this.$el); - }, - - focusAfter() { - focusNext(this.$el); - }, - - userPage - } -}); -</script> - -<style lang="scss" scoped> -.lxwezrsl { - position: relative; - transition: box-shadow 0.1s ease; - overflow: hidden; - contain: content; - - &:focus-visible { - outline: none; - - &:after { - content: ""; - pointer-events: none; - display: block; - position: absolute; - z-index: 10; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: auto; - width: calc(100% - 8px); - height: calc(100% - 8px); - border: dashed 1px var(--focus); - border-radius: var(--radius); - box-sizing: border-box; - } - } - - &:hover > .article > .main > .footer > .button { - opacity: 1; - } - - > .reply-to { - opacity: 0.7; - padding-bottom: 0; - } - - > .reply-to-more { - opacity: 0.7; - } - - > .renote { - display: flex; - align-items: center; - padding: 16px 32px 8px 32px; - line-height: 28px; - white-space: pre; - color: var(--renote); - - > .avatar { - flex-shrink: 0; - display: inline-block; - width: 28px; - height: 28px; - margin: 0 8px 0 0; - border-radius: 6px; - } - - > i { - margin-right: 4px; - } - - > span { - overflow: hidden; - flex-shrink: 1; - text-overflow: ellipsis; - white-space: nowrap; - - > .name { - font-weight: bold; - } - } - - > .info { - margin-left: auto; - font-size: 0.9em; - - > .time { - flex-shrink: 0; - color: inherit; - - > .dropdownIcon { - margin-right: 4px; - } - } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } - } - } - - > .renote + .article { - padding-top: 8px; - } - - > .article { - padding: 32px; - font-size: 1.1em; - - > .header { - display: flex; - position: relative; - margin-bottom: 16px; - - > .avatar { - display: block; - flex-shrink: 0; - width: 58px; - height: 58px; - } - - > .body { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - padding-left: 16px; - font-size: 0.95em; - - > .top { - > .name { - font-weight: bold; - } - - > .is-bot { - flex-shrink: 0; - align-self: center; - margin: 0 0.5em; - padding: 4px 6px; - font-size: 80%; - border: solid 0.5px var(--divider); - border-radius: 4px; - } - - > .admin, - > .moderator { - margin-right: 0.5em; - color: var(--badge); - } - } - } - } - - > .main { - > .body { - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - > .text { - overflow-wrap: break-word; - - > .reply { - color: var(--accent); - margin-right: 0.5em; - } - - > .rp { - margin-left: 4px; - font-style: oblique; - color: var(--renote); - } - - > .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); - padding: 12px; - margin-top: 8px; - } - } - - > .url-preview { - margin-top: 8px; - } - - > .poll { - font-size: 80%; - } - - > .renote { - padding: 8px 0; - - > * { - padding: 16px; - border: dashed 1px var(--renote); - border-radius: 8px; - } - } - } - - > .channel { - opacity: 0.7; - font-size: 80%; - } - } - - > .footer { - > .info { - margin: 16px 0; - opacity: 0.7; - font-size: 0.9em; - } - - > .button { - margin: 0; - padding: 8px; - opacity: 0.7; - - &:not(:last-child) { - margin-right: 28px; - } - - &:hover { - color: var(--fgHighlighted); - } - - > .count { - display: inline; - margin: 0 0 0 8px; - opacity: 0.7; - } - - &.reacted { - color: var(--accent); - } - } - } - } - } - - > .reply { - border-top: solid 0.5px var(--divider); - } - - &.max-width_500px { - font-size: 0.9em; - } - - &.max-width_450px { - > .renote { - padding: 8px 16px 0 16px; - } - - > .article { - padding: 16px; - - > .header { - > .avatar { - width: 50px; - height: 50px; - } - } - } - } - - &.max-width_350px { - > .article { - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 18px; - } - } - } - } - } - } - - &.max-width_300px { - font-size: 0.825em; - - > .article { - > .header { - > .avatar { - width: 50px; - height: 50px; - } - } - - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 12px; - } - } - } - } - } - } -} - -.muted { - padding: 8px; - text-align: center; - opacity: 0.7; -} -</style> diff --git a/src/client/components/note-header.vue b/src/client/components/note-header.vue deleted file mode 100644 index 80bfea9b07..0000000000 --- a/src/client/components/note-header.vue +++ /dev/null @@ -1,115 +0,0 @@ -<template> -<header class="kkwtjztg"> - <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id"> - <MkUserName :user="note.user"/> - </MkA> - <div class="is-bot" v-if="note.user.isBot">bot</div> - <div class="username"><MkAcct :user="note.user"/></div> - <div class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></div> - <div class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></div> - <div class="info"> - <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span> - <MkA class="created-at" :to="notePage(note)"> - <MkTime :time="note.createdAt"/> - </MkA> - <span class="visibility" v-if="note.visibility !== 'public'"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> - </div> -</header> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import notePage from '@client/filters/note'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - note: { - type: Object, - required: true - }, - }, - - data() { - return { - }; - }, - - methods: { - notePage, - userPage - } -}); -</script> - -<style lang="scss" scoped> -.kkwtjztg { - display: flex; - align-items: baseline; - white-space: nowrap; - - > .name { - flex-shrink: 1; - display: block; - margin: 0 .5em 0 0; - padding: 0; - overflow: hidden; - font-size: 1em; - font-weight: bold; - text-decoration: none; - text-overflow: ellipsis; - - &:hover { - text-decoration: underline; - } - } - - > .is-bot { - flex-shrink: 0; - align-self: center; - margin: 0 .5em 0 0; - padding: 1px 6px; - font-size: 80%; - border: solid 0.5px var(--divider); - border-radius: 3px; - } - - > .admin, - > .moderator { - flex-shrink: 0; - margin-right: 0.5em; - color: var(--badge); - } - - > .username { - flex-shrink: 9999999; - margin: 0 .5em 0 0; - overflow: hidden; - text-overflow: ellipsis; - } - - > .info { - flex-shrink: 0; - margin-left: auto; - font-size: 0.9em; - - > .mobile { - margin-right: 8px; - } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } - } -} -</style> diff --git a/src/client/components/note-preview.vue b/src/client/components/note-preview.vue deleted file mode 100644 index a474a01341..0000000000 --- a/src/client/components/note-preview.vue +++ /dev/null @@ -1,98 +0,0 @@ -<template> -<div class="fefdfafb" v-size="{ min: [350, 500] }"> - <MkAvatar class="avatar" :user="$i"/> - <div class="main"> - <div class="header"> - <MkUserName :user="$i"/> - </div> - <div class="body"> - <div class="content"> - <Mfm :text="text" :author="$i" :i="$i"/> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - components: { - }, - - props: { - text: { - type: String, - required: true - } - }, -}); -</script> - -<style lang="scss" scoped> -.fefdfafb { - display: flex; - margin: 0; - padding: 0; - overflow: clip; - font-size: 0.95em; - - &.min-width_350px { - > .avatar { - margin: 0 10px 0 0; - width: 44px; - height: 44px; - } - } - - &.min-width_500px { - > .avatar { - margin: 0 12px 0 0; - width: 48px; - height: 48px; - } - } - - > .avatar { - flex-shrink: 0; - display: block; - margin: 0 10px 0 0; - width: 40px; - height: 40px; - border-radius: 8px; - } - - > .main { - flex: 1; - min-width: 0; - - > .header { - margin-bottom: 2px; - } - - > .body { - - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - > .text { - cursor: default; - margin: 0; - padding: 0; - } - } - } - } -} -</style> diff --git a/src/client/components/note-simple.vue b/src/client/components/note-simple.vue deleted file mode 100644 index 406a475cd9..0000000000 --- a/src/client/components/note-simple.vue +++ /dev/null @@ -1,113 +0,0 @@ -<template> -<div class="yohlumlk" v-size="{ min: [350, 500] }"> - <MkAvatar class="avatar" :user="note.user"/> - <div class="main"> - <XNoteHeader class="header" :note="note" :mini="true"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <span class="text" v-if="note.cw != ''">{{ note.cw }}</span> - <XCwButton v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <XSubNote-content class="text" :note="note"/> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XNoteHeader from './note-header.vue'; -import XSubNoteContent from './sub-note-content.vue'; -import XCwButton from './cw-button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XNoteHeader, - XSubNoteContent, - XCwButton, - }, - - props: { - note: { - type: Object, - required: true - } - }, - - data() { - return { - showContent: false - }; - } -}); -</script> - -<style lang="scss" scoped> -.yohlumlk { - display: flex; - margin: 0; - padding: 0; - overflow: clip; - font-size: 0.95em; - - &.min-width_350px { - > .avatar { - margin: 0 10px 0 0; - width: 44px; - height: 44px; - } - } - - &.min-width_500px { - > .avatar { - margin: 0 12px 0 0; - width: 48px; - height: 48px; - } - } - - > .avatar { - flex-shrink: 0; - display: block; - margin: 0 10px 0 0; - width: 40px; - height: 40px; - border-radius: 8px; - } - - > .main { - flex: 1; - min-width: 0; - - > .header { - margin-bottom: 2px; - } - - > .body { - - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - > .text { - cursor: default; - margin: 0; - padding: 0; - } - } - } - } -} -</style> diff --git a/src/client/components/note.sub.vue b/src/client/components/note.sub.vue deleted file mode 100644 index 157b65ec5c..0000000000 --- a/src/client/components/note.sub.vue +++ /dev/null @@ -1,146 +0,0 @@ -<template> -<div class="wrpstxzv" :class="{ children }" v-size="{ max: [450] }"> - <div class="main"> - <MkAvatar class="avatar" :user="note.user"/> - <div class="body"> - <XNoteHeader class="header" :note="note" :mini="true"/> - <div class="body"> - <p v-if="note.cw != null" class="cw"> - <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" /> - <XCwButton v-model="showContent" :note="note"/> - </p> - <div class="content" v-show="note.cw == null || showContent"> - <XSubNote-content class="text" :note="note"/> - </div> - </div> - </div> - </div> - <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XNoteHeader from './note-header.vue'; -import XSubNoteContent from './sub-note-content.vue'; -import XCwButton from './cw-button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - name: 'XSub', - - components: { - XNoteHeader, - XSubNoteContent, - XCwButton, - }, - - props: { - note: { - type: Object, - required: true - }, - detail: { - type: Boolean, - required: false, - default: false - }, - children: { - type: Boolean, - required: false, - default: false - }, - // TODO - truncate: { - type: Boolean, - default: true - } - }, - - data() { - return { - showContent: false, - replies: [], - }; - }, - - created() { - if (this.detail) { - os.api('notes/children', { - noteId: this.note.id, - limit: 5 - }).then(replies => { - this.replies = replies; - }); - } - }, -}); -</script> - -<style lang="scss" scoped> -.wrpstxzv { - padding: 16px 32px; - font-size: 0.9em; - - &.max-width_450px { - padding: 14px 16px; - } - - &.children { - padding: 10px 0 0 16px; - font-size: 1em; - - &.max-width_450px { - padding: 10px 0 0 8px; - } - } - - > .main { - display: flex; - - > .avatar { - flex-shrink: 0; - display: block; - margin: 0 8px 0 0; - width: 38px; - height: 38px; - border-radius: 8px; - } - - > .body { - flex: 1; - min-width: 0; - - > .header { - margin-bottom: 2px; - } - - > .body { - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - > .text { - margin: 0; - padding: 0; - } - } - } - } - } - - > .reply { - border-left: solid 0.5px var(--divider); - margin-top: 10px; - } -} -</style> diff --git a/src/client/components/note.vue b/src/client/components/note.vue deleted file mode 100644 index 681e819a22..0000000000 --- a/src/client/components/note.vue +++ /dev/null @@ -1,1228 +0,0 @@ -<template> -<div - class="tkcbzcuz" - v-if="!muted" - v-show="!isDeleted" - :tabindex="!isDeleted ? '-1' : null" - :class="{ renote: isRenote }" - v-hotkey="keymap" - v-size="{ max: [500, 450, 350, 300] }" -> - <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> - <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> - <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> - <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> - <div class="renote" v-if="isRenote"> - <MkAvatar class="avatar" :user="note.user"/> - <i class="fas fa-retweet"></i> - <I18n :src="$ts.renotedBy" tag="span"> - <template #user> - <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId"> - <MkUserName :user="note.user"/> - </MkA> - </template> - </I18n> - <div class="info"> - <button class="_button time" @click="showRenoteMenu()" ref="renoteTime"> - <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> - <MkTime :time="note.createdAt"/> - </button> - <span class="visibility" v-if="note.visibility !== 'public'"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span> - </div> - </div> - <article class="article" @contextmenu.stop="onContextmenu"> - <MkAvatar class="avatar" :user="appearNote.user"/> - <div class="main"> - <XNoteHeader class="header" :note="appearNote" :mini="true"/> - <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> - <div class="body"> - <p v-if="appearNote.cw != null" class="cw"> - <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <XCwButton v-model="showContent" :note="appearNote"/> - </p> - <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent"> - <div class="text"> - <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> - <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> - <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> - <a class="rp" v-if="appearNote.renote != null">RN:</a> - <div class="translation" v-if="translating || translation"> - <MkLoading v-if="translating" mini/> - <div class="translated" v-else> - <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b> - {{ translation.text }} - </div> - </div> - </div> - <div class="files" v-if="appearNote.files.length > 0"> - <XMediaList :media-list="appearNote.files"/> - </div> - <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/> - <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/> - <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div> - <button v-if="collapsed" class="fade _button" @click="collapsed = false"> - <span>{{ $ts.showMore }}</span> - </button> - </div> - <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> - </div> - <footer class="footer"> - <XReactionsViewer :note="appearNote" ref="reactionsViewer"/> - <button @click="reply()" class="button _button"> - <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template> - <template v-else><i class="fas fa-reply"></i></template> - <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> - </button> - <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton"> - <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> - </button> - <button v-else class="button _button"> - <i class="fas fa-ban"></i> - </button> - <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton"> - <i class="fas fa-plus"></i> - </button> - <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton"> - <i class="fas fa-minus"></i> - </button> - <button class="button _button" @click="menu()" ref="menuButton"> - <i class="fas fa-ellipsis-h"></i> - </button> - </footer> - </div> - </article> -</div> -<div v-else class="muted" @click="muted = false"> - <I18n :src="$ts.userSaysSomething" tag="small"> - <template #name> - <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId"> - <MkUserName :user="appearNote.user"/> - </MkA> - </template> - </I18n> -</div> -</template> - -<script lang="ts"> -import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; -import * as mfm from 'mfm-js'; -import { sum } from '../../prelude/array'; -import XSub from './note.sub.vue'; -import XNoteHeader from './note-header.vue'; -import XNoteSimple from './note-simple.vue'; -import XReactionsViewer from './reactions-viewer.vue'; -import XMediaList from './media-list.vue'; -import XCwButton from './cw-button.vue'; -import XPoll from './poll.vue'; -import { pleaseLogin } from '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -export default defineComponent({ - components: { - XSub, - XNoteHeader, - XNoteSimple, - XReactionsViewer, - XMediaList, - XCwButton, - XPoll, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), - }, - - inject: { - inChannel: { - default: null - }, - }, - - props: { - note: { - type: Object, - required: true - }, - pinned: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['update:note'], - - data() { - return { - connection: null, - replies: [], - showContent: false, - collapsed: false, - isDeleted: false, - muted: false, - translation: null, - translating: false, - }; - }, - - computed: { - rs() { - return this.$store.state.reactions; - }, - keymap(): any { - return { - 'r': () => this.reply(true), - 'e|a|plus': () => this.react(true), - 'q': () => this.renote(true), - 'f|b': this.favorite, - 'delete|ctrl+d': this.del, - 'ctrl+q': this.renoteDirectly, - 'up|k|shift+tab': this.focusBefore, - 'down|j|tab': this.focusAfter, - 'esc': this.blur, - 'm|o': () => this.menu(true), - 's': this.toggleShowContent, - '1': () => this.reactDirectly(this.rs[0]), - '2': () => this.reactDirectly(this.rs[1]), - '3': () => this.reactDirectly(this.rs[2]), - '4': () => this.reactDirectly(this.rs[3]), - '5': () => this.reactDirectly(this.rs[4]), - '6': () => this.reactDirectly(this.rs[5]), - '7': () => this.reactDirectly(this.rs[6]), - '8': () => this.reactDirectly(this.rs[7]), - '9': () => this.reactDirectly(this.rs[8]), - '0': () => this.reactDirectly(this.rs[9]), - }; - }, - - isRenote(): boolean { - return (this.note.renote && - this.note.text == null && - this.note.fileIds.length == 0 && - this.note.poll == null); - }, - - appearNote(): any { - return this.isRenote ? this.note.renote : this.note; - }, - - isMyNote(): boolean { - return this.$i && (this.$i.id === this.appearNote.userId); - }, - - isMyRenote(): boolean { - return this.$i && (this.$i.id === this.note.userId); - }, - - canRenote(): boolean { - return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote; - }, - - reactionsCount(): number { - return this.appearNote.reactions - ? sum(Object.values(this.appearNote.reactions)) - : 0; - }, - - urls(): string[] { - if (this.appearNote.text) { - return extractUrlFromMfm(mfm.parse(this.appearNote.text)); - } else { - return null; - } - }, - - showTicker() { - if (this.$store.state.instanceTicker === 'always') return true; - if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true; - return false; - } - }, - - async created() { - if (this.$i) { - this.connection = os.stream; - } - - this.collapsed = this.appearNote.cw == null && this.appearNote.text && ( - (this.appearNote.text.split('\n').length > 9) || - (this.appearNote.text.length > 500) - ); - this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords); - - // plugin - if (noteViewInterruptors.length > 0) { - let result = this.note; - for (const interruptor of noteViewInterruptors) { - result = await interruptor.handler(JSON.parse(JSON.stringify(result))); - } - this.$emit('update:note', Object.freeze(result)); - } - }, - - mounted() { - this.capture(true); - - if (this.$i) { - this.connection.on('_connected_', this.onStreamConnected); - } - }, - - beforeUnmount() { - this.decapture(true); - - if (this.$i) { - this.connection.off('_connected_', this.onStreamConnected); - } - }, - - methods: { - updateAppearNote(v) { - this.$emit('update:note', Object.freeze(this.isRenote ? { - ...this.note, - renote: { - ...this.note.renote, - ...v - } - } : { - ...this.note, - ...v - })); - }, - - readPromo() { - os.api('promo/read', { - noteId: this.appearNote.id - }); - this.isDeleted = true; - }, - - capture(withHandler = false) { - if (this.$i) { - // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する - this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id }); - if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated); - } - }, - - decapture(withHandler = false) { - if (this.$i) { - this.connection.send('un', { - id: this.appearNote.id - }); - if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated); - } - }, - - onStreamConnected() { - this.capture(); - }, - - onStreamNoteUpdated(data) { - const { type, id, body } = data; - - if (id !== this.appearNote.id) return; - - switch (type) { - case 'reacted': { - const reaction = body.reaction; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - if (body.emoji) { - const emojis = this.appearNote.emojis || []; - if (!emojis.includes(body.emoji)) { - n.emojis = [...emojis, body.emoji]; - } - } - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (this.appearNote.reactions || {})[reaction] || 0; - - // Increment the count - n.reactions = { - ...this.appearNote.reactions, - [reaction]: currentCount + 1 - }; - - if (body.userId === this.$i.id) { - n.myReaction = reaction; - } - - this.updateAppearNote(n); - break; - } - - case 'unreacted': { - const reaction = body.reaction; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる - const currentCount = (this.appearNote.reactions || {})[reaction] || 0; - - // Decrement the count - n.reactions = { - ...this.appearNote.reactions, - [reaction]: Math.max(0, currentCount - 1) - }; - - if (body.userId === this.$i.id) { - n.myReaction = null; - } - - this.updateAppearNote(n); - break; - } - - case 'pollVoted': { - const choice = body.choice; - - // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので) - let n = { - ...this.appearNote, - }; - - const choices = [...this.appearNote.poll.choices]; - choices[choice] = { - ...choices[choice], - votes: choices[choice].votes + 1, - ...(body.userId === this.$i.id ? { - isVoted: true - } : {}) - }; - - n.poll = { - ...this.appearNote.poll, - choices: choices - }; - - this.updateAppearNote(n); - break; - } - - case 'deleted': { - this.isDeleted = true; - break; - } - } - }, - - reply(viaKeyboard = false) { - pleaseLogin(); - os.post({ - reply: this.appearNote, - animation: !viaKeyboard, - }, () => { - this.focus(); - }); - }, - - renote(viaKeyboard = false) { - pleaseLogin(); - this.blur(); - os.popupMenu([{ - text: this.$ts.renote, - icon: 'fas fa-retweet', - action: () => { - os.api('notes/create', { - renoteId: this.appearNote.id - }); - } - }, { - text: this.$ts.quote, - icon: 'fas fa-quote-right', - action: () => { - os.post({ - renote: this.appearNote, - }); - } - }], this.$refs.renoteButton, { - viaKeyboard - }); - }, - - renoteDirectly() { - os.apiWithDialog('notes/create', { - renoteId: this.appearNote.id - }, undefined, (res: any) => { - os.dialog({ - type: 'success', - text: this.$ts.renoted, - }); - }, (e: Error) => { - if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') { - os.dialog({ - type: 'error', - text: this.$ts.cantRenote, - }); - } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') { - os.dialog({ - type: 'error', - text: this.$ts.cantReRenote, - }); - } - }); - }, - - react(viaKeyboard = false) { - pleaseLogin(); - this.blur(); - reactionPicker.show(this.$refs.reactButton, reaction => { - os.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - }, () => { - this.focus(); - }); - }, - - reactDirectly(reaction) { - os.api('notes/reactions/create', { - noteId: this.appearNote.id, - reaction: reaction - }); - }, - - undoReact(note) { - const oldReaction = note.myReaction; - if (!oldReaction) return; - os.api('notes/reactions/delete', { - noteId: note.id - }); - }, - - favorite() { - pleaseLogin(); - os.apiWithDialog('notes/favorites/create', { - noteId: this.appearNote.id - }, undefined, (res: any) => { - os.dialog({ - type: 'success', - text: this.$ts.favorited, - }); - }, (e: Error) => { - if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') { - os.dialog({ - type: 'error', - text: this.$ts.alreadyFavorited, - }); - } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') { - os.dialog({ - type: 'error', - text: this.$ts.cantFavorite, - }); - } - }); - }, - - del() { - os.dialog({ - type: 'warning', - text: this.$ts.noteDeleteConfirm, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - os.api('notes/delete', { - noteId: this.appearNote.id - }); - }); - }, - - delEdit() { - os.dialog({ - type: 'warning', - text: this.$ts.deleteAndEditConfirm, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) return; - - os.api('notes/delete', { - noteId: this.appearNote.id - }); - - os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel }); - }); - }, - - toggleFavorite(favorite: boolean) { - os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { - noteId: this.appearNote.id - }); - }, - - toggleWatch(watch: boolean) { - os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { - noteId: this.appearNote.id - }); - }, - - toggleThreadMute(mute: boolean) { - os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { - noteId: this.appearNote.id - }); - }, - - getMenu() { - let menu; - if (this.$i) { - const statePromise = os.api('notes/state', { - noteId: this.appearNote.id - }); - - menu = [{ - icon: 'fas fa-copy', - text: this.$ts.copyContent, - action: this.copyContent - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: this.copyLink - }, (this.appearNote.url || this.appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: this.$ts.showOnRemote, - action: () => { - window.open(this.appearNote.url || this.appearNote.uri, '_blank'); - } - } : undefined, - { - icon: 'fas fa-share-alt', - text: this.$ts.share, - action: this.share - }, - this.$instance.translatorAvailable ? { - icon: 'fas fa-language', - text: this.$ts.translate, - action: this.translate - } : undefined, - null, - statePromise.then(state => state.isFavorited ? { - icon: 'fas fa-star', - text: this.$ts.unfavorite, - action: () => this.toggleFavorite(false) - } : { - icon: 'fas fa-star', - text: this.$ts.favorite, - action: () => this.toggleFavorite(true) - }), - { - icon: 'fas fa-paperclip', - text: this.$ts.clip, - action: () => this.clip() - }, - (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? { - icon: 'fas fa-eye-slash', - text: this.$ts.unwatch, - action: () => this.toggleWatch(false) - } : { - icon: 'fas fa-eye', - text: this.$ts.watch, - action: () => this.toggleWatch(true) - }) : undefined, - statePromise.then(state => state.isMutedThread ? { - icon: 'fas fa-comment-slash', - text: this.$ts.unmuteThread, - action: () => this.toggleThreadMute(false) - } : { - icon: 'fas fa-comment-slash', - text: this.$ts.muteThread, - action: () => this.toggleThreadMute(true) - }), - this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? { - icon: 'fas fa-thumbtack', - text: this.$ts.unpin, - action: () => this.togglePin(false) - } : { - icon: 'fas fa-thumbtack', - text: this.$ts.pin, - action: () => this.togglePin(true) - } : undefined, - ...(this.$i.isModerator || this.$i.isAdmin ? [ - null, - { - icon: 'fas fa-bullhorn', - text: this.$ts.promote, - action: this.promote - }] - : [] - ), - ...(this.appearNote.userId != this.$i.id ? [ - null, - { - icon: 'fas fa-exclamation-circle', - text: this.$ts.reportAbuse, - action: () => { - const u = `${url}/notes/${this.appearNote.id}`; - os.popup(import('@client/components/abuse-report-window.vue'), { - user: this.appearNote.user, - initialComment: `Note: ${u}\n-----\n` - }, {}, 'closed'); - } - }] - : [] - ), - ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ - null, - this.appearNote.userId == this.$i.id ? { - icon: 'fas fa-edit', - text: this.$ts.deleteAndEdit, - action: this.delEdit - } : undefined, - { - icon: 'fas fa-trash-alt', - text: this.$ts.delete, - danger: true, - action: this.del - }] - : [] - )] - .filter(x => x !== undefined); - } else { - menu = [{ - icon: 'fas fa-copy', - text: this.$ts.copyContent, - action: this.copyContent - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: this.copyLink - }, (this.appearNote.url || this.appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: this.$ts.showOnRemote, - action: () => { - window.open(this.appearNote.url || this.appearNote.uri, '_blank'); - } - } : undefined] - .filter(x => x !== undefined); - } - - if (noteActions.length > 0) { - menu = menu.concat([null, ...noteActions.map(action => ({ - icon: 'fas fa-plug', - text: action.title, - action: () => { - action.handler(this.appearNote); - } - }))]); - } - - return menu; - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (window.getSelection().toString() !== '') return; - - if (this.$store.state.useReactionPickerForContextMenu) { - e.preventDefault(); - this.react(); - } else { - os.contextMenu(this.getMenu(), e).then(this.focus); - } - }, - - menu(viaKeyboard = false) { - os.popupMenu(this.getMenu(), this.$refs.menuButton, { - viaKeyboard - }).then(this.focus); - }, - - showRenoteMenu(viaKeyboard = false) { - if (!this.isMyRenote) return; - os.popupMenu([{ - text: this.$ts.unrenote, - icon: 'fas fa-trash-alt', - danger: true, - action: () => { - os.api('notes/delete', { - noteId: this.note.id - }); - this.isDeleted = true; - } - }], this.$refs.renoteTime, { - viaKeyboard: viaKeyboard - }); - }, - - toggleShowContent() { - this.showContent = !this.showContent; - }, - - copyContent() { - copyToClipboard(this.appearNote.text); - os.success(); - }, - - copyLink() { - copyToClipboard(`${url}/notes/${this.appearNote.id}`); - os.success(); - }, - - togglePin(pin: boolean) { - os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { - noteId: this.appearNote.id - }, undefined, null, e => { - if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { - os.dialog({ - type: 'error', - text: this.$ts.pinLimitExceeded - }); - } - }); - }, - - async clip() { - const clips = await os.api('clips/list'); - os.popupMenu([{ - icon: 'fas fa-plus', - text: this.$ts.createNew, - action: async () => { - const { canceled, result } = await os.form(this.$ts.createNewClip, { - name: { - type: 'string', - label: this.$ts.name - }, - description: { - type: 'string', - required: false, - multiline: true, - label: this.$ts.description - }, - isPublic: { - type: 'boolean', - label: this.$ts.public, - default: false - } - }); - if (canceled) return; - - const clip = await os.apiWithDialog('clips/create', result); - - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); - } - }, null, ...clips.map(clip => ({ - text: clip.name, - action: () => { - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id }); - } - }))], this.$refs.menuButton, { - }).then(this.focus); - }, - - async promote() { - const { canceled, result: days } = await os.dialog({ - title: this.$ts.numberOfDays, - input: { type: 'number' } - }); - - if (canceled) return; - - os.apiWithDialog('admin/promo/create', { - noteId: this.appearNote.id, - expiresAt: Date.now() + (86400000 * days) - }); - }, - - share() { - navigator.share({ - title: this.$t('noteOf', { user: this.appearNote.user.name }), - text: this.appearNote.text, - url: `${url}/notes/${this.appearNote.id}` - }); - }, - - async translate() { - if (this.translation != null) return; - this.translating = true; - const res = await os.api('notes/translate', { - noteId: this.appearNote.id, - targetLang: localStorage.getItem('lang') || navigator.language, - }); - this.translating = false; - this.translation = res; - }, - - focus() { - this.$el.focus(); - }, - - blur() { - this.$el.blur(); - }, - - focusBefore() { - focusPrev(this.$el); - }, - - focusAfter() { - focusNext(this.$el); - }, - - userPage - } -}); -</script> - -<style lang="scss" scoped> -.tkcbzcuz { - position: relative; - transition: box-shadow 0.1s ease; - overflow: clip; - contain: content; - - // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 - // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう - // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 - // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる - // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) - //content-visibility: auto; - //contain-intrinsic-size: 0 128px; - - &:focus-visible { - outline: none; - - &:after { - content: ""; - pointer-events: none; - display: block; - position: absolute; - z-index: 10; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: auto; - width: calc(100% - 8px); - height: calc(100% - 8px); - border: dashed 1px var(--focus); - border-radius: var(--radius); - box-sizing: border-box; - } - } - - &:hover > .article > .main > .footer > .button { - opacity: 1; - } - - > .info { - display: flex; - align-items: center; - padding: 16px 32px 8px 32px; - line-height: 24px; - font-size: 90%; - white-space: pre; - color: #d28a3f; - - > i { - margin-right: 4px; - } - - > .hide { - margin-left: auto; - color: inherit; - } - } - - > .info + .article { - padding-top: 8px; - } - - > .reply-to { - opacity: 0.7; - padding-bottom: 0; - } - - > .renote { - display: flex; - align-items: center; - padding: 16px 32px 8px 32px; - line-height: 28px; - white-space: pre; - color: var(--renote); - - > .avatar { - flex-shrink: 0; - display: inline-block; - width: 28px; - height: 28px; - margin: 0 8px 0 0; - border-radius: 6px; - } - - > i { - margin-right: 4px; - } - - > span { - overflow: hidden; - flex-shrink: 1; - text-overflow: ellipsis; - white-space: nowrap; - - > .name { - font-weight: bold; - } - } - - > .info { - margin-left: auto; - font-size: 0.9em; - - > .time { - flex-shrink: 0; - color: inherit; - - > .dropdownIcon { - margin-right: 4px; - } - } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } - } - } - - > .renote + .article { - padding-top: 8px; - } - - > .article { - display: flex; - padding: 28px 32px 18px; - - > .avatar { - flex-shrink: 0; - display: block; - margin: 0 14px 8px 0; - width: 58px; - height: 58px; - position: sticky; - top: calc(22px + var(--stickyTop, 0px)); - left: 0; - } - - > .main { - flex: 1; - min-width: 0; - - > .body { - > .cw { - cursor: default; - display: block; - margin: 0; - padding: 0; - overflow-wrap: break-word; - - > .text { - margin-right: 8px; - } - } - - > .content { - &.collapsed { - position: relative; - max-height: 9em; - overflow: hidden; - - > .fade { - display: block; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 64px; - background: linear-gradient(0deg, var(--panel), var(--X15)); - - > span { - display: inline-block; - background: var(--panel); - padding: 6px 10px; - font-size: 0.8em; - border-radius: 999px; - box-shadow: 0 2px 6px rgb(0 0 0 / 20%); - } - - &:hover { - > span { - background: var(--panelHighlight); - } - } - } - } - - > .text { - overflow-wrap: break-word; - - > .reply { - color: var(--accent); - margin-right: 0.5em; - } - - > .rp { - margin-left: 4px; - font-style: oblique; - color: var(--renote); - } - - > .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); - padding: 12px; - margin-top: 8px; - } - } - - > .url-preview { - margin-top: 8px; - } - - > .poll { - font-size: 80%; - } - - > .renote { - padding: 8px 0; - - > * { - padding: 16px; - border: dashed 1px var(--renote); - border-radius: 8px; - } - } - } - - > .channel { - opacity: 0.7; - font-size: 80%; - } - } - - > .footer { - > .button { - margin: 0; - padding: 8px; - opacity: 0.7; - - &:not(:last-child) { - margin-right: 28px; - } - - &:hover { - color: var(--fgHighlighted); - } - - > .count { - display: inline; - margin: 0 0 0 8px; - opacity: 0.7; - } - - &.reacted { - color: var(--accent); - } - } - } - } - } - - > .reply { - border-top: solid 0.5px var(--divider); - } - - &.max-width_500px { - font-size: 0.9em; - } - - &.max-width_450px { - > .renote { - padding: 8px 16px 0 16px; - } - - > .info { - padding: 8px 16px 0 16px; - } - - > .article { - padding: 14px 16px 9px; - - > .avatar { - margin: 0 10px 8px 0; - width: 50px; - height: 50px; - top: calc(14px + var(--stickyTop, 0px)); - } - } - } - - &.max-width_350px { - > .article { - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 18px; - } - } - } - } - } - } - - &.max-width_300px { - font-size: 0.825em; - - > .article { - > .avatar { - width: 44px; - height: 44px; - } - - > .main { - > .footer { - > .button { - &:not(:last-child) { - margin-right: 12px; - } - } - } - } - } - } -} - -.muted { - padding: 8px; - text-align: center; - opacity: 0.7; -} -</style> diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue deleted file mode 100644 index 919cb29952..0000000000 --- a/src/client/components/notes.vue +++ /dev/null @@ -1,130 +0,0 @@ -<template> -<transition name="fade" mode="out-in"> - <MkLoading v-if="fetching"/> - - <MkError v-else-if="error" @retry="init()"/> - - <div class="_fullinfo" v-else-if="empty"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noNotes }}</div> - </div> - - <div v-else class="giivymft" :class="{ noGap }"> - <div v-show="more && reversed" style="margin-bottom: var(--margin);"> - <MkButton style="margin: 0 auto;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> - - <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes"> - <XNote class="qtqtichx" :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/> - </XList> - - <div v-show="more && !reversed" style="margin-top: var(--margin);"> - <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import paging from '@client/scripts/paging'; -import XNote from './note.vue'; -import XList from './date-separated-list.vue'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - XNote, XList, MkButton, - }, - - mixins: [ - paging({ - before: (self) => { - self.$emit('before'); - }, - - after: (self, e) => { - self.$emit('after', e); - } - }), - ], - - props: { - pagination: { - required: true - }, - prop: { - type: String, - required: false - }, - noGap: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['before', 'after'], - - computed: { - notes(): any[] { - return this.prop ? this.items.map(item => item[this.prop]) : this.items; - }, - - reversed(): boolean { - return this.pagination.reversed; - } - }, - - methods: { - updated(oldValue, newValue) { - const i = this.notes.findIndex(n => n === oldValue); - if (this.prop) { - this.items[i][this.prop] = newValue; - } else { - this.items[i] = newValue; - } - }, - - focus() { - this.$refs.notes.focus(); - } - } -}); -</script> - -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} - -.giivymft { - &.noGap { - > .notes { - background: var(--panel); - } - } - - &:not(.noGap) { - > .notes { - background: var(--bg); - - .qtqtichx { - background: var(--panel); - border-radius: var(--radius); - } - } - } -} -</style> diff --git a/src/client/components/notification-setting-window.vue b/src/client/components/notification-setting-window.vue deleted file mode 100644 index 14e0b76cc6..0000000000 --- a/src/client/components/notification-setting-window.vue +++ /dev/null @@ -1,99 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="400" - :height="450" - :with-ok-button="true" - :ok-button-disabled="false" - @ok="ok()" - @close="$refs.dialog.close()" - @closed="$emit('closed')" -> - <template #header>{{ $ts.notificationSetting }}</template> - <div class="_monolithic_"> - <div v-if="showGlobalToggle" class="_section"> - <MkSwitch v-model="useGlobalSetting"> - {{ $ts.useGlobalSetting }} - <template #caption>{{ $ts.useGlobalSettingDesc }}</template> - </MkSwitch> - </div> - <div v-if="!useGlobalSetting" class="_section"> - <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo> - <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> - <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> - <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch> - </div> - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import MkSwitch from './form/switch.vue'; -import MkInfo from './ui/info.vue'; -import MkButton from './ui/button.vue'; -import { notificationTypes } from '@/types'; - -export default defineComponent({ - components: { - XModalWindow, - MkSwitch, - MkInfo, - MkButton - }, - - props: { - includingTypes: { - // TODO: これで型に合わないものを弾いてくれるのかどうか要調査 - type: Array as PropType<typeof notificationTypes[number][]>, - required: false, - default: null, - }, - showGlobalToggle: { - type: Boolean, - required: false, - default: true, - } - }, - - emits: ['done', 'closed'], - - data() { - return { - typesMap: {} as Record<typeof notificationTypes[number], boolean>, - useGlobalSetting: false, - notificationTypes, - }; - }, - - created() { - this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle; - - for (const type of this.notificationTypes) { - this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type); - } - }, - - methods: { - ok() { - const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][]) - .filter(type => this.typesMap[type]); - - this.$emit('done', { includingTypes }); - this.$refs.dialog.close(); - }, - - disableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = false; - } - }, - - enableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = true; - } - } - } -}); -</script> diff --git a/src/client/components/notification.vue b/src/client/components/notification.vue deleted file mode 100644 index ce1fa5b160..0000000000 --- a/src/client/components/notification.vue +++ /dev/null @@ -1,362 +0,0 @@ -<template> -<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }" ref="elRef"> - <div class="head"> - <MkAvatar v-if="notification.user" class="icon" :user="notification.user"/> - <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/> - <div class="sub-icon" :class="notification.type"> - <i v-if="notification.type === 'follow'" class="fas fa-plus"></i> - <i v-else-if="notification.type === 'receiveFollowRequest'" class="fas fa-clock"></i> - <i v-else-if="notification.type === 'followRequestAccepted'" class="fas fa-check"></i> - <i v-else-if="notification.type === 'groupInvited'" class="fas fa-id-card-alt"></i> - <i v-else-if="notification.type === 'renote'" class="fas fa-retweet"></i> - <i v-else-if="notification.type === 'reply'" class="fas fa-reply"></i> - <i v-else-if="notification.type === 'mention'" class="fas fa-at"></i> - <i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i> - <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i> - <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> - <XReactionIcon v-else-if="notification.type === 'reaction'" - :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction" - :custom-emojis="notification.note.emojis" - :no-style="true" - @touchstart.passive="onReactionMouseover" - @mouseover="onReactionMouseover" - @mouseleave="onReactionMouseleave" - @touchend="onReactionMouseleave" - ref="reactionRef" - /> - </div> - </div> - <div class="tail"> - <header> - <MkA v-if="notification.user" class="name" :to="userPage(notification.user)" v-user-preview="notification.user.id"><MkUserName :user="notification.user"/></MkA> - <span v-else>{{ notification.header }}</span> - <MkTime :time="notification.createdAt" v-if="withTime" class="time"/> - </header> - <MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> - <i class="fas fa-quote-left"></i> - <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> - <i class="fas fa-quote-right"></i> - </MkA> - <MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)"> - <i class="fas fa-quote-left"></i> - <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/> - <i class="fas fa-quote-right"></i> - </MkA> - <MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> - <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> - </MkA> - <MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> - <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> - </MkA> - <MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> - <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> - </MkA> - <MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> - <i class="fas fa-quote-left"></i> - <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/> - <i class="fas fa-quote-right"></i> - </MkA> - <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> - <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span> - <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span> - <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $ts.reject }}</button></div></span> - <span v-if="notification.type === 'app'" class="text"> - <Mfm :text="notification.body" :nowrap="!full"/> - </span> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, ref, onMounted, onUnmounted } from 'vue'; -import { getNoteSummary } from '@/misc/get-note-summary'; -import XReactionIcon from './reaction-icon.vue'; -import MkFollowButton from './follow-button.vue'; -import XReactionTooltip from './reaction-tooltip.vue'; -import notePage from '@client/filters/note'; -import { userPage } from '@client/filters/user'; -import { i18n } from '@client/i18n'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XReactionIcon, MkFollowButton - }, - - props: { - notification: { - type: Object, - required: true, - }, - withTime: { - type: Boolean, - required: false, - default: false, - }, - full: { - type: Boolean, - required: false, - default: false, - }, - }, - - setup(props) { - const elRef = ref<HTMLElement>(null); - const reactionRef = ref(null); - - onMounted(() => { - let readObserver: IntersectionObserver = null; - let connection = null; - - if (!props.notification.isRead) { - readObserver = new IntersectionObserver((entries, observer) => { - if (!entries.some(entry => entry.isIntersecting)) return; - os.stream.send('readNotification', { - id: props.notification.id - }); - entries.map(({ target }) => observer.unobserve(target)); - }); - - readObserver.observe(elRef.value); - - connection = os.stream.useChannel('main'); - connection.on('readAllNotifications', () => readObserver.unobserve(elRef.value)); - } - - onUnmounted(() => { - if (readObserver) readObserver.unobserve(elRef.value); - if (connection) connection.dispose(); - }); - }); - - const followRequestDone = ref(false); - const groupInviteDone = ref(false); - - const acceptFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/accept', { userId: props.notification.user.id }); - }; - - const rejectFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/reject', { userId: props.notification.user.id }); - }; - - const acceptGroupInvitation = () => { - groupInviteDone.value = true; - os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); - }; - - const rejectGroupInvitation = () => { - groupInviteDone.value = true; - os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); - }; - - let isReactionHovering = false; - let reactionTooltipTimeoutId; - - const onReactionMouseover = () => { - if (isReactionHovering) return; - isReactionHovering = true; - reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300); - }; - - const onReactionMouseleave = () => { - if (!isReactionHovering) return; - isReactionHovering = false; - clearTimeout(reactionTooltipTimeoutId); - closeReactionTooltip(); - }; - - let changeReactionTooltipShowingState: () => void; - - const openReactionTooltip = () => { - closeReactionTooltip(); - if (!isReactionHovering) return; - - const showing = ref(true); - os.popup(XReactionTooltip, { - showing, - reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, - emojis: props.notification.note.emojis, - source: reactionRef.value.$el, - }, {}, 'closed'); - - changeReactionTooltipShowingState = () => { - showing.value = false; - }; - }; - - const closeReactionTooltip = () => { - if (changeReactionTooltipShowingState != null) { - changeReactionTooltipShowingState(); - changeReactionTooltipShowingState = null; - } - }; - - return { - getNoteSummary: (text: string) => getNoteSummary(text, i18n.locale), - followRequestDone, - groupInviteDone, - notePage, - userPage, - acceptFollowRequest, - rejectFollowRequest, - acceptGroupInvitation, - rejectGroupInvitation, - onReactionMouseover, - onReactionMouseleave, - elRef, - reactionRef, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.qglefbjs { - position: relative; - box-sizing: border-box; - padding: 24px 32px; - font-size: 0.9em; - overflow-wrap: break-word; - display: flex; - contain: content; - - &.max-width_600px { - padding: 16px; - font-size: 0.9em; - } - - &.max-width_500px { - padding: 12px; - font-size: 0.8em; - } - - &:after { - content: ""; - display: block; - clear: both; - } - - > .head { - position: sticky; - top: 0; - flex-shrink: 0; - width: 42px; - height: 42px; - margin-right: 8px; - - > .icon { - display: block; - width: 100%; - height: 100%; - border-radius: 6px; - } - - > .sub-icon { - position: absolute; - z-index: 1; - bottom: -2px; - right: -2px; - width: 20px; - height: 20px; - box-sizing: border-box; - border-radius: 100%; - background: var(--panel); - box-shadow: 0 0 0 3px var(--panel); - font-size: 12px; - text-align: center; - - &:empty { - display: none; - } - - > * { - color: #fff; - width: 100%; - height: 100%; - } - - &.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited { - padding: 3px; - background: #36aed2; - pointer-events: none; - } - - &.renote { - padding: 3px; - background: #36d298; - pointer-events: none; - } - - &.quote { - padding: 3px; - background: #36d298; - pointer-events: none; - } - - &.reply { - padding: 3px; - background: #007aff; - pointer-events: none; - } - - &.mention { - padding: 3px; - background: #88a6b7; - pointer-events: none; - } - - &.pollVote { - padding: 3px; - background: #88a6b7; - pointer-events: none; - } - } - } - - > .tail { - flex: 1; - min-width: 0; - - > header { - display: flex; - align-items: baseline; - white-space: nowrap; - - > .name { - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - overflow: hidden; - } - - > .time { - margin-left: auto; - font-size: 0.9em; - } - } - - > .text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - > i { - vertical-align: super; - font-size: 50%; - opacity: 0.5; - } - - > i:first-child { - margin-right: 4px; - } - - > i:last-child { - margin-left: 4px; - } - } - } -} -</style> diff --git a/src/client/components/notifications.vue b/src/client/components/notifications.vue deleted file mode 100644 index 78c1cce0c7..0000000000 --- a/src/client/components/notifications.vue +++ /dev/null @@ -1,159 +0,0 @@ -<template> -<transition name="fade" mode="out-in"> - <MkLoading v-if="fetching"/> - - <MkError v-else-if="error" @retry="init()"/> - - <p class="mfcuwfyp" v-else-if="empty">{{ $ts.noNotifications }}</p> - - <div v-else> - <XList class="elsfgstc" :items="items" v-slot="{ item: notification }" :no-gap="true"> - <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/> - <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> - </XList> - - <MkButton primary style="margin: var(--margin) auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent, PropType, markRaw } from 'vue'; -import paging from '@client/scripts/paging'; -import XNotification from './notification.vue'; -import XList from './date-separated-list.vue'; -import XNote from './note.vue'; -import { notificationTypes } from '@/types'; -import * as os from '@client/os'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - XNotification, - XList, - XNote, - MkButton, - }, - - mixins: [ - paging({}), - ], - - props: { - includeTypes: { - type: Array as PropType<typeof notificationTypes[number][]>, - required: false, - default: null, - }, - unreadOnly: { - type: Boolean, - required: false, - default: false, - }, - }, - - data() { - return { - connection: null, - pagination: { - endpoint: 'i/notifications', - limit: 10, - params: () => ({ - includeTypes: this.allIncludeTypes || undefined, - unreadOnly: this.unreadOnly, - }) - }, - }; - }, - - computed: { - allIncludeTypes() { - return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x)); - } - }, - - watch: { - includeTypes: { - handler() { - this.reload(); - }, - deep: true - }, - unreadOnly: { - handler() { - this.reload(); - }, - }, - // TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、 - // mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す - '$i.mutingNotificationTypes': { - handler() { - if (this.includeTypes === null) { - this.reload(); - } - }, - deep: true - } - }, - - mounted() { - this.connection = markRaw(os.stream.useChannel('main')); - this.connection.on('notification', this.onNotification); - }, - - beforeUnmount() { - this.connection.dispose(); - }, - - methods: { - onNotification(notification) { - const isMuted = !this.allIncludeTypes.includes(notification.type); - if (isMuted || document.visibilityState === 'visible') { - os.stream.send('readNotification', { - id: notification.id - }); - } - - if (!isMuted) { - this.prepend({ - ...notification, - isRead: document.visibilityState === 'visible' - }); - } - }, - - noteUpdated(oldValue, newValue) { - const i = this.items.findIndex(n => n.note === oldValue); - this.items[i] = { - ...this.items[i], - note: newValue - }; - }, - } -}); -</script> - -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} - -.mfcuwfyp { - margin: 0; - padding: 16px; - text-align: center; - color: var(--fg); -} - -.elsfgstc { - background: var(--panel); -} -</style> diff --git a/src/client/components/number-diff.vue b/src/client/components/number-diff.vue deleted file mode 100644 index 690f89dd59..0000000000 --- a/src/client/components/number-diff.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }"> - <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot> -</span> -</template> - -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import number from '@client/filters/number'; - -export default defineComponent({ - props: { - value: { - type: Number, - required: true - }, - }, - - setup(props) { - const isPlus = computed(() => props.value > 0); - const isMinus = computed(() => props.value < 0); - const isZero = computed(() => props.value === 0); - return { - isPlus, - isMinus, - isZero, - number, - }; - } -}); -</script> - -<style lang="scss" scoped> -.ceaaebcd { - &.isPlus { - color: var(--success); - } - - &.isMinus { - color: var(--error); - } - - &.isZero { - opacity: 0.5; - } -} -</style> diff --git a/src/client/components/page-preview.vue b/src/client/components/page-preview.vue deleted file mode 100644 index 090c4a6a6c..0000000000 --- a/src/client/components/page-preview.vue +++ /dev/null @@ -1,162 +0,0 @@ -<template> -<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1"> - <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> - <article> - <header> - <h1 :title="page.title">{{ page.title }}</h1> - </header> - <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p> - <footer> - <img class="icon" :src="page.user.avatarUrl"/> - <p>{{ userName(page.user) }}</p> - </footer> - </article> -</MkA> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { userName } from '@client/filters/user'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - page: { - type: Object, - required: true - }, - }, - methods: { - userName - } -}); -</script> - -<style lang="scss" scoped> -.vhpxefrj { - display: block; - - &:hover { - text-decoration: none; - color: var(--accent); - } - - > .thumbnail { - width: 100%; - height: 200px; - background-position: center; - background-size: cover; - display: flex; - justify-content: center; - align-items: center; - - > button { - font-size: 3.5em; - opacity: 0.7; - - &:hover { - font-size: 4em; - opacity: 0.9; - } - } - - & + article { - left: 100px; - width: calc(100% - 100px); - } - } - - > article { - padding: 16px; - - > header { - margin-bottom: 8px; - - > h1 { - margin: 0; - font-size: 1em; - color: var(--urlPreviewTitle); - } - } - - > p { - margin: 0; - color: var(--urlPreviewText); - font-size: 0.8em; - } - - > footer { - margin-top: 8px; - height: 16px; - - > img { - display: inline-block; - width: 16px; - height: 16px; - margin-right: 4px; - vertical-align: top; - } - - > p { - display: inline-block; - margin: 0; - color: var(--urlPreviewInfo); - font-size: 0.8em; - line-height: 16px; - vertical-align: top; - } - } - } - - @media (max-width: 700px) { - > .thumbnail { - position: relative; - width: 100%; - height: 100px; - - & + article { - left: 0; - width: 100%; - } - } - } - - @media (max-width: 550px) { - font-size: 12px; - - > .thumbnail { - height: 80px; - } - - > article { - padding: 12px; - } - } - - @media (max-width: 500px) { - font-size: 10px; - - > .thumbnail { - height: 70px; - } - - > article { - padding: 8px; - - > header { - margin-bottom: 4px; - } - - > footer { - margin-top: 4px; - - > img { - width: 12px; - height: 12px; - } - } - } - } -} - -</style> diff --git a/src/client/components/page-window.vue b/src/client/components/page-window.vue deleted file mode 100644 index bc7c5b7a19..0000000000 --- a/src/client/components/page-window.vue +++ /dev/null @@ -1,167 +0,0 @@ -<template> -<XWindow ref="window" - :initial-width="500" - :initial-height="500" - :can-resize="true" - :close-button="true" - :contextmenu="contextmenu" - @closed="$emit('closed')" -> - <template #header> - <template v-if="pageInfo"> - <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i> - <span>{{ pageInfo.title }}</span> - </template> - </template> - <template #headerLeft> - <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button> - </template> - <div class="yrolvcoq"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <component :is="component" v-bind="props" :ref="changePage"/> - </MkStickyContainer> - </div> -</XWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XWindow from '@client/components/ui/window.vue'; -import { popout } from '@client/scripts/popout'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - XWindow, - }, - - inject: { - sideViewHook: { - default: null - } - }, - - provide() { - return { - navHook: (path) => { - this.navigate(path); - }, - shouldHeaderThin: true, - }; - }, - - props: { - initialPath: { - type: String, - required: true, - }, - initialComponent: { - type: Object, - required: true, - }, - initialProps: { - type: Object, - required: false, - default: () => {}, - }, - }, - - emits: ['closed'], - - data() { - return { - pageInfo: null, - path: this.initialPath, - component: this.initialComponent, - props: this.initialProps, - history: [], - }; - }, - - computed: { - url(): string { - return url + this.path; - }, - - contextmenu() { - return [{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: this.expand - }, this.sideViewHook ? { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.sideViewHook(this.path); - this.$refs.window.close(); - } - } : undefined, { - icon: 'fas fa-external-link-alt', - text: this.$ts.popout, - action: this.popout - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }]; - }, - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - navigate(path, record = true) { - if (record) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - }, - - back() { - this.navigate(this.history.pop(), false); - }, - - close() { - this.$refs.window.close(); - }, - - expand() { - this.$router.push(this.path); - this.$refs.window.close(); - }, - - popout() { - popout(this.path, this.$el); - this.$refs.window.close(); - }, - }, -}); -</script> - -<style lang="scss" scoped> -.yrolvcoq { - min-height: 100%; -} -</style> diff --git a/src/client/components/page/page.block.vue b/src/client/components/page/page.block.vue deleted file mode 100644 index ffd9ce89f9..0000000000 --- a/src/client/components/page/page.block.vue +++ /dev/null @@ -1,44 +0,0 @@ -<template> -<component :is="'x-' + block.type" :block="block" :hpml="hpml" :key="block.id" :h="h"/> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; -import XText from './page.text.vue'; -import XSection from './page.section.vue'; -import XImage from './page.image.vue'; -import XButton from './page.button.vue'; -import XNumberInput from './page.number-input.vue'; -import XTextInput from './page.text-input.vue'; -import XTextareaInput from './page.textarea-input.vue'; -import XSwitch from './page.switch.vue'; -import XIf from './page.if.vue'; -import XTextarea from './page.textarea.vue'; -import XPost from './page.post.vue'; -import XCounter from './page.counter.vue'; -import XRadioButton from './page.radio-button.vue'; -import XCanvas from './page.canvas.vue'; -import XNote from './page.note.vue'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { Block } from '@client/scripts/hpml/block'; - -export default defineComponent({ - components: { - XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote - }, - props: { - block: { - type: Object as PropType<Block>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - }, - h: { - type: Number, - required: true - } - }, -}); -</script> diff --git a/src/client/components/page/page.button.vue b/src/client/components/page/page.button.vue deleted file mode 100644 index c6ae675212..0000000000 --- a/src/client/components/page/page.button.vue +++ /dev/null @@ -1,66 +0,0 @@ -<template> -<div> - <MkButton class="kudkigyw" @click="click()" :primary="block.primary">{{ hpml.interpolate(block.text) }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, PropType, unref } from 'vue'; -import MkButton from '../ui/button.vue'; -import * as os from '@client/os'; -import { ButtonBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; - -export default defineComponent({ - components: { - MkButton - }, - props: { - block: { - type: Object as PropType<ButtonBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - methods: { - click() { - if (this.block.action === 'dialog') { - this.hpml.eval(); - os.dialog({ - text: this.hpml.interpolate(this.block.content) - }); - } else if (this.block.action === 'resetRandom') { - this.hpml.updateRandomSeed(Math.random()); - this.hpml.eval(); - } else if (this.block.action === 'pushEvent') { - os.api('page-push', { - pageId: this.hpml.page.id, - event: this.block.event, - ...(this.block.var ? { - var: unref(this.hpml.vars)[this.block.var] - } : {}) - }); - - os.dialog({ - type: 'success', - text: this.hpml.interpolate(this.block.message) - }); - } else if (this.block.action === 'callAiScript') { - this.hpml.callAiScript(this.block.fn); - } - } - } -}); -</script> - -<style lang="scss" scoped> -.kudkigyw { - display: inline-block; - min-width: 200px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/src/client/components/page/page.canvas.vue b/src/client/components/page/page.canvas.vue deleted file mode 100644 index e26db597f2..0000000000 --- a/src/client/components/page/page.canvas.vue +++ /dev/null @@ -1,49 +0,0 @@ -<template> -<div class="ysrxegms"> - <canvas ref="canvas" :width="block.width" :height="block.height"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; -import * as os from '@client/os'; -import { CanvasBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; - -export default defineComponent({ - props: { - block: { - type: Object as PropType<CanvasBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const canvas: Ref<any> = ref(null); - - onMounted(() => { - props.hpml.registerCanvas(props.block.name, canvas.value); - }); - - return { - canvas - }; - } -}); -</script> - -<style lang="scss" scoped> -.ysrxegms { - display: inline-block; - vertical-align: bottom; - overflow: auto; - max-width: 100%; - - > canvas { - display: block; - } -} -</style> diff --git a/src/client/components/page/page.counter.vue b/src/client/components/page/page.counter.vue deleted file mode 100644 index dad7ac3da0..0000000000 --- a/src/client/components/page/page.counter.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> -<div> - <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkButton from '../ui/button.vue'; -import * as os from '@client/os'; -import { CounterVarBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; - -export default defineComponent({ - components: { - MkButton - }, - props: { - block: { - type: Object as PropType<CounterVarBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function click() { - props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1)); - props.hpml.eval(); - } - - return { - click - }; - } -}); -</script> - -<style lang="scss" scoped> -.llumlmnx { - display: inline-block; - min-width: 300px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/src/client/components/page/page.if.vue b/src/client/components/page/page.if.vue deleted file mode 100644 index a70525e07c..0000000000 --- a/src/client/components/page/page.if.vue +++ /dev/null @@ -1,31 +0,0 @@ -<template> -<div v-show="hpml.vars.value[block.var]"> - <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h"/> -</div> -</template> - -<script lang="ts"> -import { IfBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { defineComponent, defineAsyncComponent, PropType } from 'vue'; - -export default defineComponent({ - components: { - XBlock: defineAsyncComponent(() => import('./page.block.vue')) - }, - props: { - block: { - type: Object as PropType<IfBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - }, - h: { - type: Number, - required: true - } - }, -}); -</script> diff --git a/src/client/components/page/page.image.vue b/src/client/components/page/page.image.vue deleted file mode 100644 index 14dedc98a0..0000000000 --- a/src/client/components/page/page.image.vue +++ /dev/null @@ -1,40 +0,0 @@ -<template> -<div class="lzyxtsnt"> - <img v-if="image" :src="image.url"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; -import * as os from '@client/os'; -import { ImageBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; - -export default defineComponent({ - props: { - block: { - type: Object as PropType<ImageBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId); - - return { - image - }; - } -}); -</script> - -<style lang="scss" scoped> -.lzyxtsnt { - > img { - max-width: 100%; - } -} -</style> diff --git a/src/client/components/page/page.note.vue b/src/client/components/page/page.note.vue deleted file mode 100644 index 7a3f88bb1f..0000000000 --- a/src/client/components/page/page.note.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<div class="voxdxuby"> - <XNote v-if="note && !block.detailed" v-model:note="note" :key="note.id + ':normal'"/> - <XNoteDetailed v-if="note && block.detailed" v-model:note="note" :key="note.id + ':detail'"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, PropType, Ref, ref } from 'vue'; -import XNote from '@client/components/note.vue'; -import XNoteDetailed from '@client/components/note-detailed.vue'; -import * as os from '@client/os'; -import { NoteBlock } from '@client/scripts/hpml/block'; - -export default defineComponent({ - components: { - XNote, - XNoteDetailed, - }, - props: { - block: { - type: Object as PropType<NoteBlock>, - required: true - } - }, - setup(props, ctx) { - const note: Ref<Record<string, any> | null> = ref(null); - - onMounted(() => { - os.api('notes/show', { noteId: props.block.note }) - .then(result => { - note.value = result; - }); - }); - - return { - note - }; - } -}); -</script> - -<style lang="scss" scoped> -.voxdxuby { - margin: 1em 0; -} -</style> diff --git a/src/client/components/page/page.number-input.vue b/src/client/components/page/page.number-input.vue deleted file mode 100644 index 5d9168f130..0000000000 --- a/src/client/components/page/page.number-input.vue +++ /dev/null @@ -1,55 +0,0 @@ -<template> -<div> - <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="number"> - <template #label>{{ hpml.interpolate(block.text) }}</template> - </MkInput> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkInput from '../form/input.vue'; -import * as os from '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { NumberInputVarBlock } from '@client/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkInput - }, - props: { - block: { - type: Object as PropType<NumberInputVarBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue - }; - } -}); -</script> - -<style lang="scss" scoped> -.kudkigyw { - display: inline-block; - min-width: 300px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/src/client/components/page/page.post.vue b/src/client/components/page/page.post.vue deleted file mode 100644 index c20d7cade1..0000000000 --- a/src/client/components/page/page.post.vue +++ /dev/null @@ -1,109 +0,0 @@ -<template> -<div class="ngbfujlo"> - <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea> - <MkButton class="button" primary @click="post()" :disabled="posting || posted"> - <i v-if="posted" class="fas fa-check"></i> - <i v-else class="fas fa-paper-plane"></i> - </MkButton> -</div> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; -import MkTextarea from '../form/textarea.vue'; -import MkButton from '../ui/button.vue'; -import { apiUrl } from '@client/config'; -import * as os from '@client/os'; -import { PostBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; - -export default defineComponent({ - components: { - MkTextarea, - MkButton, - }, - props: { - block: { - type: Object as PropType<PostBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - data() { - return { - text: this.hpml.interpolate(this.block.text), - posted: false, - posting: false, - }; - }, - watch: { - 'hpml.vars': { - handler() { - this.text = this.hpml.interpolate(this.block.text); - }, - deep: true - } - }, - methods: { - upload() { - const promise = new Promise((ok) => { - const canvas = this.hpml.canvases[this.block.canvasId]; - canvas.toBlob(blob => { - const data = new FormData(); - data.append('file', blob); - data.append('i', this.$i.token); - if (this.$store.state.uploadFolder) { - data.append('folderId', this.$store.state.uploadFolder); - } - - fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: data - }) - .then(response => response.json()) - .then(f => { - ok(f); - }) - }); - }); - os.promiseDialog(promise); - return promise; - }, - async post() { - this.posting = true; - const file = this.block.attachCanvasImage ? await this.upload() : null; - os.apiWithDialog('notes/create', { - text: this.text === '' ? null : this.text, - fileIds: file ? [file.id] : undefined, - }).then(() => { - this.posted = true; - }); - } - } -}); -</script> - -<style lang="scss" scoped> -.ngbfujlo { - position: relative; - padding: 32px; - border-radius: 6px; - box-shadow: 0 2px 8px var(--shadow); - z-index: 1; - - > .button { - margin-top: 32px; - } - - @media (max-width: 600px) { - padding: 16px; - - > .button { - margin-top: 16px; - } - } -} -</style> diff --git a/src/client/components/page/page.radio-button.vue b/src/client/components/page/page.radio-button.vue deleted file mode 100644 index 590e59d706..0000000000 --- a/src/client/components/page/page.radio-button.vue +++ /dev/null @@ -1,45 +0,0 @@ -<template> -<div> - <div>{{ hpml.interpolate(block.title) }}</div> - <MkRadio v-for="item in block.values" :modelValue="value" @update:modelValue="updateValue($event)" :value="item" :key="item">{{ item }}</MkRadio> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkRadio from '../form/radio.vue'; -import * as os from '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { RadioButtonVarBlock } from '@client/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkRadio - }, - props: { - block: { - type: Object as PropType<RadioButtonVarBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue: string) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue - }; - } -}); -</script> diff --git a/src/client/components/page/page.section.vue b/src/client/components/page/page.section.vue deleted file mode 100644 index 81cab12501..0000000000 --- a/src/client/components/page/page.section.vue +++ /dev/null @@ -1,60 +0,0 @@ -<template> -<section class="sdgxphyu"> - <component :is="'h' + h">{{ block.title }}</component> - - <div class="children"> - <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h + 1"/> - </div> -</section> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent, PropType } from 'vue'; -import * as os from '@client/os'; -import { SectionBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; - -export default defineComponent({ - components: { - XBlock: defineAsyncComponent(() => import('./page.block.vue')) - }, - props: { - block: { - type: Object as PropType<SectionBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - }, - h: { - required: true - } - }, -}); -</script> - -<style lang="scss" scoped> -.sdgxphyu { - margin: 1.5em 0; - - > h2 { - font-size: 1.35em; - margin: 0 0 0.5em 0; - } - - > h3 { - font-size: 1em; - margin: 0 0 0.5em 0; - } - - > h4 { - font-size: 1em; - margin: 0 0 0.5em 0; - } - - > .children { - //padding 16px - } -} -</style> diff --git a/src/client/components/page/page.switch.vue b/src/client/components/page/page.switch.vue deleted file mode 100644 index 4d74e5df39..0000000000 --- a/src/client/components/page/page.switch.vue +++ /dev/null @@ -1,55 +0,0 @@ -<template> -<div class="hkcxmtwj"> - <MkSwitch :model-value="value" @update:modelValue="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkSwitch from '../form/switch.vue'; -import * as os from '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { SwitchVarBlock } from '@client/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkSwitch - }, - props: { - block: { - type: Object as PropType<SwitchVarBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue: boolean) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue - }; - } -}); -</script> - -<style lang="scss" scoped> -.hkcxmtwj { - display: inline-block; - margin: 16px auto; - - & + .hkcxmtwj { - margin-left: 16px; - } -} -</style> diff --git a/src/client/components/page/page.text-input.vue b/src/client/components/page/page.text-input.vue deleted file mode 100644 index 6e9ac0b543..0000000000 --- a/src/client/components/page/page.text-input.vue +++ /dev/null @@ -1,55 +0,0 @@ -<template> -<div> - <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="text"> - <template #label>{{ hpml.interpolate(block.text) }}</template> - </MkInput> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkInput from '../form/input.vue'; -import * as os from '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { TextInputVarBlock } from '@client/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkInput - }, - props: { - block: { - type: Object as PropType<TextInputVarBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue - }; - } -}); -</script> - -<style lang="scss" scoped> -.kudkigyw { - display: inline-block; - min-width: 300px; - max-width: 450px; - margin: 8px 0; -} -</style> diff --git a/src/client/components/page/page.text.vue b/src/client/components/page/page.text.vue deleted file mode 100644 index 580c5a93bf..0000000000 --- a/src/client/components/page/page.text.vue +++ /dev/null @@ -1,68 +0,0 @@ -<template> -<div class="mrdgzndn"> - <Mfm :text="text" :is-note="false" :i="$i" :key="text"/> - <MkUrlPreview v-for="url in urls" :url="url" :key="url" class="url"/> -</div> -</template> - -<script lang="ts"> -import { TextBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { defineAsyncComponent, defineComponent, PropType } from 'vue'; -import * as mfm from 'mfm-js'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -export default defineComponent({ - components: { - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - }, - props: { - block: { - type: Object as PropType<TextBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - data() { - return { - text: this.hpml.interpolate(this.block.text), - }; - }, - computed: { - urls(): string[] { - if (this.text) { - return extractUrlFromMfm(mfm.parse(this.text)); - } else { - return []; - } - } - }, - watch: { - 'hpml.vars': { - handler() { - this.text = this.hpml.interpolate(this.block.text); - }, - deep: true - } - }, -}); -</script> - -<style lang="scss" scoped> -.mrdgzndn { - &:not(:first-child) { - margin-top: 0.5em; - } - - &:not(:last-child) { - margin-bottom: 0.5em; - } - - > .url { - margin: 0.5em 0; - } -} -</style> diff --git a/src/client/components/page/page.textarea-input.vue b/src/client/components/page/page.textarea-input.vue deleted file mode 100644 index dfcb398937..0000000000 --- a/src/client/components/page/page.textarea-input.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> -<div> - <MkTextarea :model-value="value" @update:modelValue="updateValue($event)"> - <template #label>{{ hpml.interpolate(block.text) }}</template> - </MkTextarea> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, PropType } from 'vue'; -import MkTextarea from '../form/textarea.vue'; -import * as os from '@client/os'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { HpmlTextInput } from '@client/scripts/hpml'; -import { TextInputVarBlock } from '@client/scripts/hpml/block'; - -export default defineComponent({ - components: { - MkTextarea - }, - props: { - block: { - type: Object as PropType<TextInputVarBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - setup(props, ctx) { - const value = computed(() => { - return props.hpml.vars.value[props.block.name]; - }); - - function updateValue(newValue) { - props.hpml.updatePageVar(props.block.name, newValue); - props.hpml.eval(); - } - - return { - value, - updateValue - }; - } -}); -</script> diff --git a/src/client/components/page/page.textarea.vue b/src/client/components/page/page.textarea.vue deleted file mode 100644 index cf953bf041..0000000000 --- a/src/client/components/page/page.textarea.vue +++ /dev/null @@ -1,39 +0,0 @@ -<template> -<MkTextarea :model-value="text" readonly></MkTextarea> -</template> - -<script lang="ts"> -import { TextBlock } from '@client/scripts/hpml/block'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { defineComponent, PropType } from 'vue'; -import MkTextarea from '../form/textarea.vue'; - -export default defineComponent({ - components: { - MkTextarea - }, - props: { - block: { - type: Object as PropType<TextBlock>, - required: true - }, - hpml: { - type: Object as PropType<Hpml>, - required: true - } - }, - data() { - return { - text: this.hpml.interpolate(this.block.text), - }; - }, - watch: { - 'hpml.vars': { - handler() { - this.text = this.hpml.interpolate(this.block.text); - }, - deep: true - } - } -}); -</script> diff --git a/src/client/components/page/page.vue b/src/client/components/page/page.vue deleted file mode 100644 index f125365c3d..0000000000 --- a/src/client/components/page/page.vue +++ /dev/null @@ -1,86 +0,0 @@ -<template> -<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }" v-if="hpml"> - <XBlock v-for="child in page.content" :block="child" :hpml="hpml" :key="child.id" :h="2"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue'; -import { parse } from '@syuilo/aiscript'; -import XBlock from './page.block.vue'; -import { Hpml } from '@client/scripts/hpml/evaluator'; -import { url } from '@client/config'; -import { $i } from '@client/account'; -import { defaultStore } from '@client/store'; - -export default defineComponent({ - components: { - XBlock - }, - props: { - page: { - type: Object as PropType<Record<string, any>>, - required: true - }, - }, - setup(props, ctx) { - - const hpml = new Hpml(props.page, { - randomSeed: Math.random(), - visitor: $i, - url: url, - enableAiScript: !defaultStore.state.disablePagesScript - }); - - onMounted(() => { - nextTick(() => { - if (props.page.script && hpml.aiscript) { - let ast; - try { - ast = parse(props.page.script); - } catch (e) { - console.error(e); - /*os.dialog({ - type: 'error', - text: 'Syntax error :(' - });*/ - return; - } - hpml.aiscript.exec(ast).then(() => { - hpml.eval(); - }).catch(e => { - console.error(e); - /*os.dialog({ - type: 'error', - text: e - });*/ - }); - } else { - hpml.eval(); - } - }); - onUnmounted(() => { - if (hpml.aiscript) hpml.aiscript.abort(); - }); - }); - - return { - hpml, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.iroscrza { - &.serif { - > div { - font-family: serif; - } - } - - &.center { - text-align: center; - } -} -</style> diff --git a/src/client/components/particle.vue b/src/client/components/particle.vue deleted file mode 100644 index d82705c1e8..0000000000 --- a/src/client/components/particle.vue +++ /dev/null @@ -1,114 +0,0 @@ -<template> -<div class="vswabwbm" :style="{ top: `${y - 64}px`, left: `${x - 64}px` }" :class="{ active }"> - <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> - <circle fill="none" cx="64" cy="64"> - <animate attributeName="r" - begin="0s" dur="0.5s" - values="4; 32" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.165, 0.84, 0.44, 1" - repeatCount="1" - /> - <animate attributeName="stroke-width" - begin="0s" dur="0.5s" - values="16; 0" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.3, 0.61, 0.355, 1" - repeatCount="1" - /> - </circle> - <g fill="none" fill-rule="evenodd"> - <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color"> - <animate attributeName="r" - begin="0s" dur="0.8s" - :values="`${particle.size}; 0`" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.165, 0.84, 0.44, 1" - repeatCount="1" - /> - <animate attributeName="cx" - begin="0s" dur="0.8s" - :values="`${particle.xA}; ${particle.xB}`" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.3, 0.61, 0.355, 1" - repeatCount="1" - /> - <animate attributeName="cy" - begin="0s" dur="0.8s" - :values="`${particle.yA}; ${particle.yB}`" - calcMode="spline" - keyTimes="0; 1" - keySplines="0.3, 0.61, 0.355, 1" - repeatCount="1" - /> - </circle> - </g> - </svg> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - x: { - type: Number, - required: true - }, - y: { - type: Number, - required: true - } - }, - emits: ['end'], - data() { - const particles = []; - const origin = 64; - const colors = ['#FF1493', '#00FFFF', '#FFE202']; - - for (let i = 0; i < 12; i++) { - const angle = Math.random() * (Math.PI * 2); - const pos = Math.random() * 16; - const velocity = 16 + (Math.random() * 48); - particles.push({ - size: 4 + (Math.random() * 8), - xA: origin + (Math.sin(angle) * pos), - yA: origin + (Math.cos(angle) * pos), - xB: origin + (Math.sin(angle) * (pos + velocity)), - yB: origin + (Math.cos(angle) * (pos + velocity)), - color: colors[Math.floor(Math.random() * colors.length)] - }); - } - - return { - particles - }; - }, - mounted() { - setTimeout(() => { - this.$emit('end'); - }, 1100); - } -}); -</script> - -<style lang="scss" scoped> -.vswabwbm { - pointer-events: none; - position: fixed; - z-index: 1000000; - width: 128px; - height: 128px; - - > svg { - > circle { - stroke: var(--accent); - } - } -} -</style> diff --git a/src/client/components/poll-editor.vue b/src/client/components/poll-editor.vue deleted file mode 100644 index b28a1c8baa..0000000000 --- a/src/client/components/poll-editor.vue +++ /dev/null @@ -1,251 +0,0 @@ -<template> -<div class="zmdxowus"> - <p class="caution" v-if="choices.length < 2"> - <i class="fas fa-exclamation-triangle"></i>{{ $ts._poll.noOnlyOneChoice }} - </p> - <ul ref="choices"> - <li v-for="(choice, i) in choices" :key="i"> - <MkInput class="input" :model-value="choice" @update:modelValue="onInput(i, $event)" :placeholder="$t('_poll.choiceN', { n: i + 1 })"> - </MkInput> - <button @click="remove(i)" class="_button"> - <i class="fas fa-times"></i> - </button> - </li> - </ul> - <MkButton class="add" v-if="choices.length < 10" @click="add">{{ $ts.add }}</MkButton> - <MkButton class="add" v-else disabled>{{ $ts._poll.noMore }}</MkButton> - <section> - <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch> - <div> - <MkSelect v-model="expiration"> - <template #label>{{ $ts._poll.expiration }}</template> - <option value="infinite">{{ $ts._poll.infinite }}</option> - <option value="at">{{ $ts._poll.at }}</option> - <option value="after">{{ $ts._poll.after }}</option> - </MkSelect> - <section v-if="expiration === 'at'"> - <MkInput v-model="atDate" type="date" class="input"> - <template #label>{{ $ts._poll.deadlineDate }}</template> - </MkInput> - <MkInput v-model="atTime" type="time" class="input"> - <template #label>{{ $ts._poll.deadlineTime }}</template> - </MkInput> - </section> - <section v-if="expiration === 'after'"> - <MkInput v-model="after" type="number" class="input"> - <template #label>{{ $ts._poll.duration }}</template> - </MkInput> - <MkSelect v-model="unit"> - <option value="second">{{ $ts._time.second }}</option> - <option value="minute">{{ $ts._time.minute }}</option> - <option value="hour">{{ $ts._time.hour }}</option> - <option value="day">{{ $ts._time.day }}</option> - </MkSelect> - </section> - </div> - </section> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { addTime } from '../../prelude/time'; -import { formatDateTimeString } from '@/misc/format-time-string'; -import MkInput from './form/input.vue'; -import MkSelect from './form/select.vue'; -import MkSwitch from './form/switch.vue'; -import MkButton from './ui/button.vue'; - -export default defineComponent({ - components: { - MkInput, - MkSelect, - MkSwitch, - MkButton, - }, - - props: { - poll: { - type: Object, - required: true - } - }, - - emits: ['updated'], - - data() { - return { - choices: this.poll.choices, - multiple: this.poll.multiple, - expiration: 'infinite', - atDate: formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'), - atTime: '00:00', - after: 0, - unit: 'second', - }; - }, - - watch: { - choices: { - handler() { - this.$emit('updated', this.get()); - }, - deep: true - }, - multiple: { - handler() { - this.$emit('updated', this.get()); - }, - }, - expiration: { - handler() { - this.$emit('updated', this.get()); - }, - }, - atDate: { - handler() { - this.$emit('updated', this.get()); - }, - }, - after: { - handler() { - this.$emit('updated', this.get()); - }, - }, - unit: { - handler() { - this.$emit('updated', this.get()); - }, - }, - }, - - created() { - const poll = this.poll; - if (poll.expiresAt) { - this.expiration = 'at'; - this.atDate = this.atTime = poll.expiresAt; - } else if (typeof poll.expiredAfter === 'number') { - this.expiration = 'after'; - this.after = poll.expiredAfter / 1000; - } else { - this.expiration = 'infinite'; - } - }, - - methods: { - onInput(i, e) { - this.choices[i] = e; - }, - - add() { - this.choices.push(''); - this.$nextTick(() => { - // TODO - //(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus(); - }); - }, - - remove(i) { - this.choices = this.choices.filter((_, _i) => _i != i); - }, - - get() { - const at = () => { - return new Date(`${this.atDate} ${this.atTime}`).getTime(); - }; - - const after = () => { - let base = parseInt(this.after); - switch (this.unit) { - case 'day': base *= 24; - case 'hour': base *= 60; - case 'minute': base *= 60; - case 'second': return base *= 1000; - default: return null; - } - }; - - return { - choices: this.choices, - multiple: this.multiple, - ...( - this.expiration === 'at' ? { expiresAt: at() } : - this.expiration === 'after' ? { expiredAfter: after() } : {} - ) - }; - }, - } -}); -</script> - -<style lang="scss" scoped> -.zmdxowus { - padding: 8px; - - > .caution { - margin: 0 0 8px 0; - font-size: 0.8em; - color: #f00; - - > i { - margin-right: 4px; - } - } - - > ul { - display: block; - margin: 0; - padding: 0; - list-style: none; - - > li { - display: flex; - margin: 8px 0; - padding: 0; - width: 100%; - - > .input { - flex: 1; - margin-top: 16px; - margin-bottom: 0; - } - - > button { - width: 32px; - padding: 4px 0; - } - } - } - - > .add { - margin: 8px 0 0 0; - z-index: 1; - } - - > section { - margin: 16px 0 -16px 0; - - > div { - margin: 0 8px; - - &:last-child { - flex: 1 0 auto; - - > section { - align-items: center; - display: flex; - margin: -32px 0 0; - - > &:first-child { - margin-right: 16px; - } - - > .input { - flex: 1 0 auto; - } - } - } - } - } -} -</style> diff --git a/src/client/components/poll.vue b/src/client/components/poll.vue deleted file mode 100644 index b5d430f93b..0000000000 --- a/src/client/components/poll.vue +++ /dev/null @@ -1,174 +0,0 @@ -<template> -<div class="tivcixzd" :class="{ done: closed || isVoted }"> - <ul> - <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }"> - <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div> - <span> - <template v-if="choice.isVoted"><i class="fas fa-check"></i></template> - <Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/> - <span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span> - </span> - </li> - </ul> - <p v-if="!readOnly"> - <span>{{ $t('_poll.totalVotes', { n: total }) }}</span> - <span> · </span> - <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $ts._poll.vote : $ts._poll.showResult }}</a> - <span v-if="isVoted">{{ $ts._poll.voted }}</span> - <span v-else-if="closed">{{ $ts._poll.closed }}</span> - <span v-if="remaining > 0"> · {{ timer }}</span> - </p> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { sum } from '../../prelude/array'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - note: { - type: Object, - required: true - }, - readOnly: { - type: Boolean, - required: false, - default: false, - } - }, - data() { - return { - remaining: -1, - showResult: false, - }; - }, - computed: { - poll(): any { - return this.note.poll; - }, - total(): number { - return sum(this.poll.choices.map(x => x.votes)); - }, - closed(): boolean { - return !this.remaining; - }, - timer(): string { - return this.$t( - this.remaining >= 86400 ? '_poll.remainingDays' : - this.remaining >= 3600 ? '_poll.remainingHours' : - this.remaining >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', { - s: Math.floor(this.remaining % 60), - m: Math.floor(this.remaining / 60) % 60, - h: Math.floor(this.remaining / 3600) % 24, - d: Math.floor(this.remaining / 86400) - }); - }, - isVoted(): boolean { - return !this.poll.multiple && this.poll.choices.some(c => c.isVoted); - } - }, - created() { - this.showResult = this.readOnly || this.isVoted; - - if (this.note.poll.expiresAt) { - const update = () => { - if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000)) - requestAnimationFrame(update); - else - this.showResult = true; - }; - - update(); - } - }, - methods: { - toggleShowResult() { - this.showResult = !this.showResult; - }, - vote(id) { - if (this.readOnly || this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return; - os.api('notes/polls/vote', { - noteId: this.note.id, - choice: id - }).then(() => { - if (!this.showResult) this.showResult = !this.poll.multiple; - }); - } - } -}); -</script> - -<style lang="scss" scoped> -.tivcixzd { - > ul { - display: block; - margin: 0; - padding: 0; - list-style: none; - - > li { - display: block; - position: relative; - margin: 4px 0; - padding: 4px 8px; - border: solid 0.5px var(--divider); - border-radius: 4px; - overflow: hidden; - cursor: pointer; - - &:hover { - background: rgba(#000, 0.05); - } - - &:active { - background: rgba(#000, 0.1); - } - - > .backdrop { - position: absolute; - top: 0; - left: 0; - height: 100%; - background: var(--accent); - transition: width 1s ease; - } - - > span { - position: relative; - - > i { - margin-right: 4px; - } - - > .votes { - margin-left: 4px; - } - } - } - } - - > p { - color: var(--fg); - - a { - color: inherit; - } - } - - &.done { - > ul > li { - cursor: default; - - &:hover { - background: transparent; - } - - &:active { - background: transparent; - } - } - } -} -</style> diff --git a/src/client/components/post-form-attaches.vue b/src/client/components/post-form-attaches.vue deleted file mode 100644 index 9365365653..0000000000 --- a/src/client/components/post-form-attaches.vue +++ /dev/null @@ -1,193 +0,0 @@ -<template> -<div class="skeikyzd" v-show="files.length != 0"> - <XDraggable class="files" v-model="_files" item-key="id" animation="150" delay="100" delay-on-touch-only="true"> - <template #item="{element}"> - <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> - <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> - <div class="sensitive" v-if="element.isSensitive"> - <i class="fas fa-exclamation-triangle icon"></i> - </div> - </div> - </template> - </XDraggable> - <p class="remain">{{ 4 - files.length }}/4</p> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import MkDriveFileThumbnail from './drive-file-thumbnail.vue' -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - MkDriveFileThumbnail - }, - - props: { - files: { - type: Array, - required: true - }, - detachMediaFn: { - type: Function, - required: false - } - }, - - emits: ['updated', 'detach', 'changeSensitive', 'changeName'], - - data() { - return { - menu: null as Promise<null> | null, - - }; - }, - - computed: { - _files: { - get() { - return this.files; - }, - set(value) { - this.$emit('updated', value); - } - } - }, - - methods: { - detachMedia(id) { - if (this.detachMediaFn) { - this.detachMediaFn(id); - } else { - this.$emit('detach', id); - } - }, - toggleSensitive(file) { - os.api('drive/files/update', { - fileId: file.id, - isSensitive: !file.isSensitive - }).then(() => { - this.$emit('changeSensitive', file, !file.isSensitive); - }); - }, - async rename(file) { - const { canceled, result } = await os.dialog({ - title: this.$ts.enterFileName, - input: { - default: file.name - }, - allowEmpty: false - }); - if (canceled) return; - os.api('drive/files/update', { - fileId: file.id, - name: result - }).then(() => { - this.$emit('changeName', file, result); - file.name = result; - }); - }, - - async describe(file) { - os.popup(import("@client/components/media-caption.vue"), { - title: this.$ts.describeFile, - input: { - placeholder: this.$ts.inputNewDescription, - default: file.comment !== null ? file.comment : "", - }, - image: file - }, { - done: result => { - if (!result || result.canceled) return; - let comment = result.result; - os.api('drive/files/update', { - fileId: file.id, - comment: comment.length == 0 ? null : comment - }); - } - }, 'closed'); - }, - - showFileMenu(file, ev: MouseEvent) { - if (this.menu) return; - this.menu = os.popupMenu([{ - text: this.$ts.renameFile, - icon: 'fas fa-i-cursor', - action: () => { this.rename(file) } - }, { - text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, - icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', - action: () => { this.toggleSensitive(file) } - }, { - text: this.$ts.describeFile, - icon: 'fas fa-i-cursor', - action: () => { this.describe(file) } - }, { - text: this.$ts.attachCancel, - icon: 'fas fa-times-circle', - action: () => { this.detachMedia(file.id) } - }], ev.currentTarget || ev.target).then(() => this.menu = null); - } - } -}); -</script> - -<style lang="scss" scoped> -.skeikyzd { - padding: 8px 16px; - position: relative; - - > .files { - display: flex; - flex-wrap: wrap; - - > div { - position: relative; - width: 64px; - height: 64px; - margin-right: 4px; - border-radius: 4px; - overflow: hidden; - cursor: move; - - &:hover > .remove { - display: block; - } - - > .thumbnail { - width: 100%; - height: 100%; - z-index: 1; - color: var(--fg); - } - - > .sensitive { - display: flex; - position: absolute; - width: 64px; - height: 64px; - top: 0; - left: 0; - z-index: 2; - background: rgba(17, 17, 17, .7); - color: #fff; - - > .icon { - margin: auto; - } - } - } - } - - > .remain { - display: block; - position: absolute; - top: 8px; - right: 8px; - margin: 0; - padding: 0; - } -} -</style> diff --git a/src/client/components/post-form-dialog.vue b/src/client/components/post-form-dialog.vue deleted file mode 100644 index aa23e3891e..0000000000 --- a/src/client/components/post-form-dialog.vue +++ /dev/null @@ -1,19 +0,0 @@ -<template> -<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')" :position="'top'"> - <MkPostForm @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()" v-bind="$attrs"/> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from '@client/components/ui/modal.vue'; -import MkPostForm from '@client/components/post-form.vue'; - -export default defineComponent({ - components: { - MkModal, - MkPostForm, - }, - emits: ['closed'], -}); -</script> diff --git a/src/client/components/post-form.vue b/src/client/components/post-form.vue deleted file mode 100644 index 90df78895c..0000000000 --- a/src/client/components/post-form.vue +++ /dev/null @@ -1,980 +0,0 @@ -<template> -<div class="gafaadew" :class="{ modal, _popup: modal }" - v-size="{ max: [310, 500] }" - @dragover.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.stop="onDrop" -> - <header> - <button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button> - <div> - <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span> - <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span> - <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null"> - <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span> - <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span> - <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span> - <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span> - </button> - <button class="_button preview" @click="showPreview = !showPreview" :class="{ active: showPreview }" v-tooltip="$ts.previewNoteText"><i class="fas fa-file-code"></i></button> - <button class="submit _buttonGradate" :disabled="!canPost" @click="post" data-cy-open-post-form-submit>{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button> - </div> - </header> - <div class="form" :class="{ fixed }"> - <XNoteSimple class="preview" v-if="reply" :note="reply"/> - <XNoteSimple class="preview" v-if="renote" :note="renote"/> - <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div> - <div v-if="visibility === 'specified'" class="to-specified"> - <span style="margin-right: 8px;">{{ $ts.recipient }}</span> - <div class="visibleUsers"> - <span v-for="u in visibleUsers" :key="u.id"> - <MkAcct :user="u"/> - <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button> - </span> - <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button> - </div> - </div> - <MkInfo warn v-if="hasNotSpecifiedMentions" class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo> - <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown"> - <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" data-cy-post-form-text/> - <input v-show="withHashtags" ref="hashtags" class="hashtags" v-model="hashtags" :placeholder="$ts.hashtags" list="hashtags"> - <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> - <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/> - <XNotePreview class="preview" v-if="showPreview" :text="text"/> - <footer> - <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button> - <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button> - <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button> - <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button> - <button class="_button" @click="withHashtags = !withHashtags" :class="{ active: withHashtags }" v-tooltip="$ts.hashtags"><i class="fas fa-hashtag"></i></button> - <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button> - <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button> - </footer> - <datalist id="hashtags"> - <option v-for="hashtag in recentHashtags" :value="hashtag" :key="hashtag"/> - </datalist> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import insertTextAtCursor from 'insert-text-at-cursor'; -import { length } from 'stringz'; -import { toASCII } from 'punycode/'; -import XNoteSimple from './note-simple.vue'; -import XNotePreview from './note-preview.vue'; -import * as mfm from 'mfm-js'; -import { host, url } from '@client/config'; -import { erase, unique } from '../../prelude/array'; -import { extractMentions } from '@/misc/extract-mentions'; -import { getAcct } from '@/misc/acct'; -import { formatTimeString } from '@/misc/format-time-string'; -import { Autocomplete } from '@client/scripts/autocomplete'; -import { noteVisibilities } from '../../types'; -import * as os from '@client/os'; -import { selectFile } from '@client/scripts/select-file'; -import { defaultStore, notePostInterruptors, postFormActions } from '@client/store'; -import { isMobile } from '@client/scripts/is-mobile'; -import { throttle } from 'throttle-debounce'; -import MkInfo from '@client/components/ui/info.vue'; -import { defaultStore } from '@client/store'; - -export default defineComponent({ - components: { - XNoteSimple, - XNotePreview, - XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')), - XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')), - MkInfo, - }, - - inject: ['modal'], - - props: { - reply: { - type: Object, - required: false - }, - renote: { - type: Object, - required: false - }, - channel: { - type: Object, - required: false - }, - mention: { - type: Object, - required: false - }, - specified: { - type: Object, - required: false - }, - initialText: { - type: String, - required: false - }, - initialVisibility: { - type: String, - required: false - }, - initialFiles: { - type: Array, - required: false - }, - initialLocalOnly: { - type: Boolean, - required: false - }, - visibleUsers: { - type: Array, - required: false, - default: () => [] - }, - initialNote: { - type: Object, - required: false - }, - share: { - type: Boolean, - required: false, - default: false - }, - fixed: { - type: Boolean, - required: false, - default: false - }, - autofocus: { - type: Boolean, - required: false, - default: true - }, - }, - - emits: ['posted', 'cancel', 'esc'], - - data() { - return { - posting: false, - text: '', - files: [], - poll: null, - useCw: false, - showPreview: false, - cw: null, - localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly, - visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number], - autocomplete: null, - draghover: false, - quoteId: null, - hasNotSpecifiedMentions: false, - recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'), - imeText: '', - typing: throttle(3000, () => { - if (this.channel) { - os.stream.send('typingOnChannel', { channel: this.channel.id }); - } - }), - postFormActions, - }; - }, - - computed: { - draftKey(): string { - let key = this.channel ? `channel:${this.channel.id}` : ''; - - if (this.renote) { - key += `renote:${this.renote.id}`; - } else if (this.reply) { - key += `reply:${this.reply.id}`; - } else { - key += 'note'; - } - - return key; - }, - - placeholder(): string { - if (this.renote) { - return this.$ts._postForm.quotePlaceholder; - } else if (this.reply) { - return this.$ts._postForm.replyPlaceholder; - } else if (this.channel) { - return this.$ts._postForm.channelPlaceholder; - } else { - const xs = [ - this.$ts._postForm._placeholders.a, - this.$ts._postForm._placeholders.b, - this.$ts._postForm._placeholders.c, - this.$ts._postForm._placeholders.d, - this.$ts._postForm._placeholders.e, - this.$ts._postForm._placeholders.f - ]; - return xs[Math.floor(Math.random() * xs.length)]; - } - }, - - submitText(): string { - return this.renote - ? this.$ts.quote - : this.reply - ? this.$ts.reply - : this.$ts.note; - }, - - textLength(): number { - return length((this.text + this.imeText).trim()); - }, - - canPost(): boolean { - return !this.posting && - (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) && - (this.textLength <= this.max) && - (!this.poll || this.poll.choices.length >= 2); - }, - - max(): number { - return this.$instance ? this.$instance.maxNoteTextLength : 1000; - }, - - withHashtags: defaultStore.makeGetterSetter('postFormWithHashtags'), - hashtags: defaultStore.makeGetterSetter('postFormHashtags'), - }, - - watch: { - text() { - this.checkMissingMention(); - }, - visibleUsers: { - handler() { - this.checkMissingMention(); - }, - deep: true - } - }, - - mounted() { - if (this.initialText) { - this.text = this.initialText; - } - - if (this.initialVisibility) { - this.visibility = this.initialVisibility; - } - - if (this.initialFiles) { - this.files = this.initialFiles; - } - - if (typeof this.initialLocalOnly === 'boolean') { - this.localOnly = this.initialLocalOnly; - } - - if (this.mention) { - this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`; - this.text += ' '; - } - - if (this.reply && this.reply.user.host != null) { - this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `; - } - - if (this.reply && this.reply.text != null) { - const ast = mfm.parse(this.reply.text); - - for (const x of extractMentions(ast)) { - const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`; - - // 自分は除外 - if (this.$i.username == x.username && x.host == null) continue; - if (this.$i.username == x.username && x.host == host) continue; - - // 重複は除外 - if (this.text.indexOf(`${mention} `) != -1) continue; - - this.text += `${mention} `; - } - } - - if (this.channel) { - this.visibility = 'public'; - this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す - } - - // 公開以外へのリプライ時は元の公開範囲を引き継ぐ - if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) { - this.visibility = this.reply.visibility; - if (this.reply.visibility === 'specified') { - os.api('users/show', { - userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId) - }).then(users => { - this.visibleUsers.push(...users); - }); - - if (this.reply.userId !== this.$i.id) { - os.api('users/show', { userId: this.reply.userId }).then(user => { - this.visibleUsers.push(user); - }); - } - } - } - - if (this.specified) { - this.visibility = 'specified'; - this.visibleUsers.push(this.specified); - } - - // keep cw when reply - if (this.$store.state.keepCw && this.reply && this.reply.cw) { - this.useCw = true; - this.cw = this.reply.cw; - } - - if (this.autofocus) { - this.focus(); - - this.$nextTick(() => { - this.focus(); - }); - } - - // TODO: detach when unmount - new Autocomplete(this.$refs.text, this, { model: 'text' }); - new Autocomplete(this.$refs.cw, this, { model: 'cw' }); - new Autocomplete(this.$refs.hashtags, this, { model: 'hashtags' }); - - this.$nextTick(() => { - // 書きかけの投稿を復元 - if (!this.share && !this.mention && !this.specified) { - const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey]; - if (draft) { - this.text = draft.data.text; - this.useCw = draft.data.useCw; - this.cw = draft.data.cw; - this.visibility = draft.data.visibility; - this.localOnly = draft.data.localOnly; - this.files = (draft.data.files || []).filter(e => e); - if (draft.data.poll) { - this.poll = draft.data.poll; - } - } - } - - // 削除して編集 - if (this.initialNote) { - const init = this.initialNote; - this.text = init.text ? init.text : ''; - this.files = init.files; - this.cw = init.cw; - this.useCw = init.cw != null; - if (init.poll) { - this.poll = { - choices: init.poll.choices.map(x => x.text), - multiple: init.poll.multiple, - expiresAt: init.poll.expiresAt, - expiredAfter: init.poll.expiredAfter, - }; - } - this.visibility = init.visibility; - this.localOnly = init.localOnly; - this.quoteId = init.renote ? init.renote.id : null; - } - - this.$nextTick(() => this.watch()); - }); - }, - - methods: { - watch() { - this.$watch('text', () => this.saveDraft()); - this.$watch('useCw', () => this.saveDraft()); - this.$watch('cw', () => this.saveDraft()); - this.$watch('poll', () => this.saveDraft()); - this.$watch('files', () => this.saveDraft(), { deep: true }); - this.$watch('visibility', () => this.saveDraft()); - this.$watch('localOnly', () => this.saveDraft()); - }, - - checkMissingMention() { - if (this.visibility === 'specified') { - const ast = mfm.parse(this.text); - - for (const x of extractMentions(ast)) { - if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) { - this.hasNotSpecifiedMentions = true; - return; - } - } - this.hasNotSpecifiedMentions = false; - } - }, - - addMissingMention() { - const ast = mfm.parse(this.text); - - for (const x of extractMentions(ast)) { - if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) { - os.api('users/show', { username: x.username, host: x.host }).then(user => { - this.visibleUsers.push(user); - }); - } - } - }, - - togglePoll() { - if (this.poll) { - this.poll = null; - } else { - this.poll = { - choices: ['', ''], - multiple: false, - expiresAt: null, - expiredAfter: null, - }; - } - }, - - addTag(tag: string) { - insertTextAtCursor(this.$refs.text, ` #${tag} `); - }, - - focus() { - (this.$refs.text as any).focus(); - }, - - chooseFileFrom(ev) { - selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => { - for (const file of files) { - this.files.push(file); - } - }); - }, - - detachFile(id) { - this.files = this.files.filter(x => x.id != id); - }, - - updateFiles(files) { - this.files = files; - }, - - updateFileSensitive(file, sensitive) { - this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive; - }, - - updateFileName(file, name) { - this.files[this.files.findIndex(x => x.id === file.id)].name = name; - }, - - upload(file: File, name?: string) { - os.upload(file, this.$store.state.uploadFolder, name).then(res => { - this.files.push(res); - }); - }, - - onPollUpdate(poll) { - this.poll = poll; - this.saveDraft(); - }, - - setVisibility() { - if (this.channel) { - // TODO: information dialog - return; - } - - os.popup(import('./visibility-picker.vue'), { - currentVisibility: this.visibility, - currentLocalOnly: this.localOnly, - src: this.$refs.visibilityButton - }, { - changeVisibility: visibility => { - this.visibility = visibility; - if (this.$store.state.rememberNoteVisibility) { - this.$store.set('visibility', visibility); - } - }, - changeLocalOnly: localOnly => { - this.localOnly = localOnly; - if (this.$store.state.rememberNoteVisibility) { - this.$store.set('localOnly', localOnly); - } - } - }, 'closed'); - }, - - addVisibleUser() { - os.selectUser().then(user => { - this.visibleUsers.push(user); - }); - }, - - removeVisibleUser(user) { - this.visibleUsers = erase(user, this.visibleUsers); - }, - - clear() { - this.text = ''; - this.files = []; - this.poll = null; - this.quoteId = null; - }, - - onKeydown(e: KeyboardEvent) { - if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(); - if (e.which === 27) this.$emit('esc'); - this.typing(); - }, - - onCompositionUpdate(e: CompositionEvent) { - this.imeText = e.data; - this.typing(); - }, - - onCompositionEnd(e: CompositionEvent) { - this.imeText = ''; - }, - - async onPaste(e: ClipboardEvent) { - for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) { - if (item.kind == 'file') { - const file = item.getAsFile(); - const lio = file.name.lastIndexOf('.'); - const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; - this.upload(file, formatted); - } - } - - const paste = e.clipboardData.getData('text'); - - if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) { - e.preventDefault(); - - os.dialog({ - type: 'info', - text: this.$ts.quoteQuestion, - showCancelButton: true - }).then(({ canceled }) => { - if (canceled) { - insertTextAtCursor(this.$refs.text, paste); - return; - } - - this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; - }); - } - }, - - onDragover(e) { - if (!e.dataTransfer.items[0]) return; - const isFile = e.dataTransfer.items[0].kind == 'file'; - const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { - e.preventDefault(); - this.draghover = true; - e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; - } - }, - - onDragenter(e) { - this.draghover = true; - }, - - onDragleave(e) { - this.draghover = false; - }, - - onDrop(e): void { - this.draghover = false; - - // ファイルだったら - if (e.dataTransfer.files.length > 0) { - e.preventDefault(); - for (const x of Array.from(e.dataTransfer.files)) this.upload(x); - return; - } - - //#region ドライブのファイル - const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile != '') { - const file = JSON.parse(driveFile); - this.files.push(file); - e.preventDefault(); - } - //#endregion - }, - - saveDraft() { - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); - - data[this.draftKey] = { - updatedAt: new Date(), - data: { - text: this.text, - useCw: this.useCw, - cw: this.cw, - visibility: this.visibility, - localOnly: this.localOnly, - files: this.files, - poll: this.poll - } - }; - - localStorage.setItem('drafts', JSON.stringify(data)); - }, - - deleteDraft() { - const data = JSON.parse(localStorage.getItem('drafts') || '{}'); - - delete data[this.draftKey]; - - localStorage.setItem('drafts', JSON.stringify(data)); - }, - - async post() { - let data = { - text: this.text == '' ? undefined : this.text, - fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, - replyId: this.reply ? this.reply.id : undefined, - renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined, - channelId: this.channel ? this.channel.id : undefined, - poll: this.poll, - cw: this.useCw ? this.cw || '' : undefined, - localOnly: this.localOnly, - visibility: this.visibility, - visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined, - viaMobile: isMobile - }; - - if (this.withHashtags && this.hashtags && this.hashtags.trim() !== '') { - const hashtags = this.hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); - data.text = data.text ? `${data.text} ${hashtags}` : hashtags; - } - - // plugin - if (notePostInterruptors.length > 0) { - for (const interruptor of notePostInterruptors) { - data = await interruptor.handler(JSON.parse(JSON.stringify(data))); - } - } - - this.posting = true; - os.api('notes/create', data).then(() => { - this.clear(); - this.$nextTick(() => { - this.deleteDraft(); - this.$emit('posted'); - if (data.text && data.text != '') { - const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag); - const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[]; - localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history)))); - } - this.posting = false; - }); - }).catch(err => { - this.posting = false; - os.dialog({ - type: 'error', - text: err.message + '\n' + (err as any).id, - }); - }); - }, - - cancel() { - this.$emit('cancel'); - }, - - insertMention() { - os.selectUser().then(user => { - insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' '); - }); - }, - - async insertEmoji(ev) { - os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); - }, - - showActions(ev) { - os.popupMenu(postFormActions.map(action => ({ - text: action.title, - action: () => { - action.handler({ - text: this.text - }, (key, value) => { - if (key === 'text') { this.text = value; } - }); - } - })), ev.currentTarget || ev.target); - } - } -}); -</script> - -<style lang="scss" scoped> -.gafaadew { - position: relative; - - &.modal { - width: 100%; - max-width: 520px; - } - - > header { - z-index: 1000; - height: 66px; - - > .cancel { - padding: 0; - font-size: 20px; - width: 64px; - line-height: 66px; - } - - > div { - position: absolute; - top: 0; - right: 0; - - > .text-count { - opacity: 0.7; - line-height: 66px; - } - - > .visibility { - height: 34px; - width: 34px; - margin: 0 0 0 8px; - - & + .localOnly { - margin-left: 0 !important; - } - } - - > .local-only { - margin: 0 0 0 12px; - opacity: 0.7; - } - - > .preview { - display: inline-block; - padding: 0; - margin: 0 8px 0 0; - font-size: 16px; - width: 34px; - height: 34px; - border-radius: 6px; - - &:hover { - background: var(--X5); - } - - &.active { - color: var(--accent); - } - } - - > .submit { - margin: 16px 16px 16px 0; - padding: 0 12px; - line-height: 34px; - font-weight: bold; - vertical-align: bottom; - border-radius: 4px; - font-size: 0.9em; - - &:disabled { - opacity: 0.7; - } - - > i { - margin-left: 6px; - } - } - } - } - - > .form { - > .preview { - padding: 16px; - } - - > .with-quote { - margin: 0 0 8px 0; - color: var(--accent); - - > button { - padding: 4px 8px; - color: var(--accentAlpha04); - - &:hover { - color: var(--accentAlpha06); - } - - &:active { - color: var(--accentDarken30); - } - } - } - - > .to-specified { - padding: 6px 24px; - margin-bottom: 8px; - overflow: auto; - white-space: nowrap; - - > .visibleUsers { - display: inline; - top: -1px; - font-size: 14px; - - > button { - padding: 4px; - border-radius: 8px; - } - - > span { - margin-right: 14px; - padding: 8px 0 8px 8px; - border-radius: 8px; - background: var(--X4); - - > button { - padding: 4px 8px; - } - } - } - } - - > .hasNotSpecifiedMentions { - margin: 0 20px 16px 20px; - } - - > .cw, - > .hashtags, - > .text { - display: block; - box-sizing: border-box; - padding: 0 24px; - margin: 0; - width: 100%; - font-size: 16px; - border: none; - border-radius: 0; - background: transparent; - color: var(--fg); - font-family: inherit; - - &:focus { - outline: none; - } - - &:disabled { - opacity: 0.5; - } - } - - > .cw { - z-index: 1; - padding-bottom: 8px; - border-bottom: solid 0.5px var(--divider); - } - - > .hashtags { - z-index: 1; - padding-top: 8px; - padding-bottom: 8px; - border-top: solid 0.5px var(--divider); - } - - > .text { - max-width: 100%; - min-width: 100%; - min-height: 90px; - - &.withCw { - padding-top: 8px; - } - } - - > footer { - padding: 0 16px 16px 16px; - - > button { - display: inline-block; - padding: 0; - margin: 0; - font-size: 16px; - width: 48px; - height: 48px; - border-radius: 6px; - - &:hover { - background: var(--X5); - } - - &.active { - color: var(--accent); - } - } - } - } - - &.max-width_500px { - > header { - height: 50px; - - > .cancel { - width: 50px; - line-height: 50px; - } - - > div { - > .text-count { - line-height: 50px; - } - - > .submit { - margin: 8px; - } - } - } - - > .form { - > .to-specified { - padding: 6px 16px; - } - - > .cw, - > .hashtags, - > .text { - padding: 0 16px; - } - - > .text { - min-height: 80px; - } - - > footer { - padding: 0 8px 8px 8px; - } - } - } - - &.max-width_310px { - > .form { - > footer { - > button { - font-size: 14px; - width: 44px; - height: 44px; - } - } - } - } -} -</style> diff --git a/src/client/components/queue-chart.vue b/src/client/components/queue-chart.vue deleted file mode 100644 index f9c3eccfb5..0000000000 --- a/src/client/components/queue-chart.vue +++ /dev/null @@ -1,232 +0,0 @@ -<template> -<canvas ref="chartEl"></canvas> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; -import { - Chart, - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -} from 'chart.js'; -import number from '@client/filters/number'; -import * as os from '@client/os'; -import { defaultStore } from '@client/store'; - -Chart.register( - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -); - -const alpha = (hex, a) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - return `rgba(${r}, ${g}, ${b}, ${a})`; -}; - -export default defineComponent({ - props: { - domain: { - type: String, - required: true, - }, - connection: { - required: true, - }, - }, - - setup(props) { - const chartEl = ref<HTMLCanvasElement>(null); - - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - onMounted(() => { - const chartInstance = new Chart(chartEl.value, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'Process', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#00E396', - backgroundColor: alpha('#00E396', 0.1), - data: [] - }, { - label: 'Active', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#00BCD4', - backgroundColor: alpha('#00BCD4', 0.1), - data: [] - }, { - label: 'Waiting', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#FFB300', - backgroundColor: alpha('#FFB300', 0.1), - yAxisID: 'y2', - data: [] - }, { - label: 'Delayed', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#E53935', - borderDash: [5, 5], - fill: false, - yAxisID: 'y2', - data: [] - }], - }, - options: { - aspectRatio: 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 8, - }, - }, - scales: { - x: { - grid: { - display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - ticks: { - display: false, - maxTicksLimit: 10 - }, - }, - y: { - min: 0, - stack: 'queue', - stackWeight: 2, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - }, - y2: { - min: 0, - offset: true, - stack: 'queue', - stackWeight: 1, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - }, - }, - interaction: { - intersect: false, - }, - plugins: { - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - }, - }, - tooltip: { - mode: 'index', - animation: { - duration: 0, - }, - }, - }, - }, - }); - - const onStats = (stats) => { - chartInstance.data.labels.push(''); - chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); - chartInstance.data.datasets[1].data.push(stats[props.domain].active); - chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); - chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); - if (chartInstance.data.datasets[0].data.length > 200) { - chartInstance.data.labels.shift(); - chartInstance.data.datasets[0].data.shift(); - chartInstance.data.datasets[1].data.shift(); - chartInstance.data.datasets[2].data.shift(); - chartInstance.data.datasets[3].data.shift(); - } - chartInstance.update(); - }; - - const onStatsLog = (statsLog) => { - for (const stats of [...statsLog].reverse()) { - chartInstance.data.labels.push(''); - chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); - chartInstance.data.datasets[1].data.push(stats[props.domain].active); - chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); - chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); - if (chartInstance.data.datasets[0].data.length > 200) { - chartInstance.data.labels.shift(); - chartInstance.data.datasets[0].data.shift(); - chartInstance.data.datasets[1].data.shift(); - chartInstance.data.datasets[2].data.shift(); - chartInstance.data.datasets[3].data.shift(); - } - } - chartInstance.update(); - }; - - props.connection.on('stats', onStats); - props.connection.on('statsLog', onStatsLog); - - onUnmounted(() => { - props.connection.off('stats', onStats); - props.connection.off('statsLog', onStatsLog); - }); - }); - - return { - chartEl, - } - }, -}); -</script> - -<style lang="scss" scoped> - -</style> diff --git a/src/client/components/reaction-icon.vue b/src/client/components/reaction-icon.vue deleted file mode 100644 index c0ec955e32..0000000000 --- a/src/client/components/reaction-icon.vue +++ /dev/null @@ -1,25 +0,0 @@ -<template> -<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - reaction: { - type: String, - required: true - }, - customEmojis: { - required: false, - default: () => [] - }, - noStyle: { - type: Boolean, - required: false, - default: false - }, - }, -}); -</script> diff --git a/src/client/components/reaction-tooltip.vue b/src/client/components/reaction-tooltip.vue deleted file mode 100644 index 93143cbe81..0000000000 --- a/src/client/components/reaction-tooltip.vue +++ /dev/null @@ -1,51 +0,0 @@ -<template> -<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340"> - <div class="beeadbfb"> - <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> - <div class="name">{{ reaction.replace('@.', '') }}</div> - </div> -</MkTooltip> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkTooltip from './ui/tooltip.vue'; -import XReactionIcon from './reaction-icon.vue'; - -export default defineComponent({ - components: { - MkTooltip, - XReactionIcon, - }, - props: { - reaction: { - type: String, - required: true, - }, - emojis: { - type: Array, - required: true, - }, - source: { - required: true, - } - }, - emits: ['closed'], -}) -</script> - -<style lang="scss" scoped> -.beeadbfb { - text-align: center; - - > .icon { - display: block; - width: 60px; - margin: 0 auto; - } - - > .name { - font-size: 0.9em; - } -} -</style> diff --git a/src/client/components/reactions-viewer.details.vue b/src/client/components/reactions-viewer.details.vue deleted file mode 100644 index 7c49bd1d9c..0000000000 --- a/src/client/components/reactions-viewer.details.vue +++ /dev/null @@ -1,91 +0,0 @@ -<template> -<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340"> - <div class="bqxuuuey"> - <div class="reaction"> - <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> - <div class="name">{{ reaction.replace('@.', '') }}</div> - </div> - <div class="users"> - <template v-if="users.length <= 10"> - <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> - <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> - <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> - </b> - </template> - <template v-if="10 < users.length"> - <b v-for="u in users" :key="u.id" style="margin-right: 12px;"> - <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/> - <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/> - </b> - <span slot="omitted">+{{ count - 10 }}</span> - </template> - </div> - </div> -</MkTooltip> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkTooltip from './ui/tooltip.vue'; -import XReactionIcon from './reaction-icon.vue'; - -export default defineComponent({ - components: { - MkTooltip, - XReactionIcon - }, - props: { - reaction: { - type: String, - required: true, - }, - users: { - type: Array, - required: true, - }, - count: { - type: Number, - required: true, - }, - emojis: { - type: Array, - required: true, - }, - source: { - required: true, - } - }, - emits: ['closed'], -}) -</script> - -<style lang="scss" scoped> -.bqxuuuey { - display: flex; - - > .reaction { - flex: 1; - max-width: 100px; - text-align: center; - - > .icon { - display: block; - width: 60px; - margin: 0 auto; - } - - > .name { - font-size: 0.9em; - } - } - - > .users { - flex: 1; - min-width: 0; - font-size: 0.9em; - border-left: solid 0.5px var(--divider); - padding-left: 10px; - margin-left: 10px; - } -} -</style> diff --git a/src/client/components/reactions-viewer.reaction.vue b/src/client/components/reactions-viewer.reaction.vue deleted file mode 100644 index f47ba83f61..0000000000 --- a/src/client/components/reactions-viewer.reaction.vue +++ /dev/null @@ -1,183 +0,0 @@ -<template> -<button - class="hkzvhatu _button" - :class="{ reacted: note.myReaction == reaction, canToggle }" - @click="toggleReaction(reaction)" - v-if="count > 0" - @touchstart.passive="onMouseover" - @mouseover="onMouseover" - @mouseleave="onMouseleave" - @touchend="onMouseleave" - ref="reaction" - v-particle="canToggle" -> - <XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/> - <span>{{ count }}</span> -</button> -</template> - -<script lang="ts"> -import { defineComponent, ref } from 'vue'; -import XDetails from '@client/components/reactions-viewer.details.vue'; -import XReactionIcon from '@client/components/reaction-icon.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XReactionIcon - }, - props: { - reaction: { - type: String, - required: true, - }, - count: { - type: Number, - required: true, - }, - isInitial: { - type: Boolean, - required: true, - }, - note: { - type: Object, - required: true, - }, - }, - data() { - return { - close: null, - detailsTimeoutId: null, - isHovering: false - }; - }, - computed: { - canToggle(): boolean { - return !this.reaction.match(/@\w/) && this.$i; - }, - }, - watch: { - count(newCount, oldCount) { - if (oldCount < newCount) this.anime(); - if (this.close != null) this.openDetails(); - }, - }, - mounted() { - if (!this.isInitial) this.anime(); - }, - methods: { - toggleReaction() { - if (!this.canToggle) return; - - const oldReaction = this.note.myReaction; - if (oldReaction) { - os.api('notes/reactions/delete', { - noteId: this.note.id - }).then(() => { - if (oldReaction !== this.reaction) { - os.api('notes/reactions/create', { - noteId: this.note.id, - reaction: this.reaction - }); - } - }); - } else { - os.api('notes/reactions/create', { - noteId: this.note.id, - reaction: this.reaction - }); - } - }, - onMouseover() { - if (this.isHovering) return; - this.isHovering = true; - this.detailsTimeoutId = setTimeout(this.openDetails, 300); - }, - onMouseleave() { - if (!this.isHovering) return; - this.isHovering = false; - clearTimeout(this.detailsTimeoutId); - this.closeDetails(); - }, - openDetails() { - os.api('notes/reactions', { - noteId: this.note.id, - type: this.reaction, - limit: 11 - }).then((reactions: any[]) => { - const users = reactions - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) - .map(x => x.user); - - this.closeDetails(); - if (!this.isHovering) return; - - const showing = ref(true); - os.popup(XDetails, { - showing, - reaction: this.reaction, - emojis: this.note.emojis, - users, - count: this.count, - source: this.$refs.reaction - }, {}, 'closed'); - - this.close = () => { - showing.value = false; - }; - }); - }, - closeDetails() { - if (this.close != null) { - this.close(); - this.close = null; - } - }, - anime() { - if (document.hidden) return; - - // TODO - }, - } -}); -</script> - -<style lang="scss" scoped> -.hkzvhatu { - display: inline-block; - height: 32px; - margin: 2px; - padding: 0 6px; - border-radius: 4px; - - &.canToggle { - background: rgba(0, 0, 0, 0.05); - - &:hover { - background: rgba(0, 0, 0, 0.1); - } - } - - &:not(.canToggle) { - cursor: default; - } - - &.reacted { - background: var(--accent); - - &:hover { - background: var(--accent); - } - - > span { - color: var(--fgOnAccent); - } - } - - > span { - font-size: 0.9em; - line-height: 32px; - margin: 0 0 0 4px; - } -} -</style> diff --git a/src/client/components/reactions-viewer.vue b/src/client/components/reactions-viewer.vue deleted file mode 100644 index 94a0318734..0000000000 --- a/src/client/components/reactions-viewer.vue +++ /dev/null @@ -1,48 +0,0 @@ -<template> -<div class="tdflqwzn" :class="{ isMe }"> - <XReaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XReaction from './reactions-viewer.reaction.vue'; - -export default defineComponent({ - components: { - XReaction - }, - props: { - note: { - type: Object, - required: true - }, - }, - data() { - return { - initialReactions: new Set(Object.keys(this.note.reactions)) - }; - }, - computed: { - isMe(): boolean { - return this.$i && this.$i.id === this.note.userId; - }, - }, -}); -</script> - -<style lang="scss" scoped> -.tdflqwzn { - margin: 4px -2px 0 -2px; - - &:empty { - display: none; - } - - &.isMe { - > span { - cursor: default !important; - } - } -} -</style> diff --git a/src/client/components/remote-caution.vue b/src/client/components/remote-caution.vue deleted file mode 100644 index 985ae44694..0000000000 --- a/src/client/components/remote-caution.vue +++ /dev/null @@ -1,35 +0,0 @@ -<template> -<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - href: { - type: String, - required: true - }, - }, - data() { - return { - }; - } -}); -</script> - -<style lang="scss" scoped> -.jmgmzlwq { - font-size: 0.8em; - padding: 16px; - background: var(--infoWarnBg); - color: var(--infoWarnFg); - - > a { - margin-left: 4px; - color: var(--accent); - } -} -</style> diff --git a/src/client/components/sample.vue b/src/client/components/sample.vue deleted file mode 100644 index c8b46a80e7..0000000000 --- a/src/client/components/sample.vue +++ /dev/null @@ -1,116 +0,0 @@ -<template> -<div class="_card"> - <div class="_content"> - <MkInput v-model="text"> - <template #label>Text</template> - </MkInput> - <MkSwitch v-model="flag"> - <span>Switch is now {{ flag ? 'on' : 'off' }}</span> - </MkSwitch> - <div style="margin: 32px 0;"> - <MkRadio v-model="radio" value="misskey">Misskey</MkRadio> - <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio> - <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> - </div> - <MkButton inline>This is</MkButton> - <MkButton inline primary>the button</MkButton> - </div> - <div class="_content" style="pointer-events: none;"> - <Mfm :text="mfm"/> - </div> - <div class="_content"> - <MkButton inline primary @click="openMenu">Open menu</MkButton> - <MkButton inline primary @click="openDialog">Open dialog</MkButton> - <MkButton inline primary @click="openForm">Open form</MkButton> - <MkButton inline primary @click="openDrive">Open drive</MkButton> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkRadio from '@client/components/form/radio.vue'; -import * as os from '@client/os'; -import * as config from '@client/config'; - -export default defineComponent({ - components: { - MkButton, - MkInput, - MkSwitch, - MkTextarea, - MkRadio, - }, - - data() { - return { - text: '', - flag: true, - radio: 'misskey', - mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.` - } - }, - - methods: { - async openDialog() { - os.dialog({ - type: 'warning', - title: 'Oh my Aichan', - text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', - }); - }, - - async openForm() { - os.form('Example form', { - foo: { - type: 'boolean', - default: true, - label: 'This is a boolean property' - }, - bar: { - type: 'number', - default: 300, - label: 'This is a number property' - }, - baz: { - type: 'string', - default: 'Misskey makes you happy.', - label: 'This is a string property' - }, - }); - }, - - async openDrive() { - os.selectDriveFile(); - }, - - async selectUser() { - os.selectUser(); - }, - - async openMenu(ev) { - os.popupMenu([{ - type: 'label', - text: 'Fruits' - }, { - text: 'Create some apples', - action: () => {}, - }, { - text: 'Read some oranges', - action: () => {}, - }, { - text: 'Update some melons', - action: () => {}, - }, null, { - text: 'Delete some bananas', - danger: true, - action: () => {}, - }], ev.currentTarget || ev.target); - }, - } -}); -</script> diff --git a/src/client/components/signin-dialog.vue b/src/client/components/signin-dialog.vue deleted file mode 100644 index 6c38c07d78..0000000000 --- a/src/client/components/signin-dialog.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="370" - :height="400" - @close="$refs.dialog.close()" - @closed="$emit('closed')" -> - <template #header>{{ $ts.login }}</template> - - <MkSignin :auto-set="autoSet" @login="onLogin"/> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import MkSignin from './signin.vue'; - -export default defineComponent({ - components: { - MkSignin, - XModalWindow, - }, - - props: { - autoSet: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['done', 'closed'], - - methods: { - onLogin(res) { - this.$emit('done', res); - this.$refs.dialog.close(); - } - } -}); -</script> diff --git a/src/client/components/signin.vue b/src/client/components/signin.vue deleted file mode 100755 index d6e1ee8b68..0000000000 --- a/src/client/components/signin.vue +++ /dev/null @@ -1,240 +0,0 @@ -<template> -<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit"> - <div class="auth _section _formRoot"> - <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div> - <div class="normal-signin" v-if="!totpLogin"> - <MkInput class="_formBlock" v-model="username" :placeholder="$ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:modelValue="onUsernameChange" data-cy-signin-username> - <template #prefix>@</template> - <template #suffix>@{{ host }}</template> - </MkInput> - <MkInput class="_formBlock" v-model="password" :placeholder="$ts.password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required data-cy-signin-password> - <template #prefix><i class="fas fa-lock"></i></template> - <template #caption><button class="_textButton" @click="resetPassword" type="button">{{ $ts.forgotPassword }}</button></template> - </MkInput> - <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> - </div> - <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }"> - <div v-if="user && user.securityKeys" class="twofa-group tap-group"> - <p>{{ $ts.tapSecurityKey }}</p> - <MkButton @click="queryKey" v-if="!queryingKey"> - {{ $ts.retry }} - </MkButton> - </div> - <div class="or-hr" v-if="user && user.securityKeys"> - <p class="or-msg">{{ $ts.or }}</p> - </div> - <div class="twofa-group totp-group"> - <p style="margin-bottom:0;">{{ $ts.twoStepAuthentication }}</p> - <MkInput v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required> - <template #label>{{ $ts.password }}</template> - <template #prefix><i class="fas fa-lock"></i></template> - </MkInput> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> - <template #label>{{ $ts.token }}</template> - <template #prefix><i class="fas fa-gavel"></i></template> - </MkInput> - <MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton> - </div> - </div> - </div> - <div class="social _section"> - <a class="_borderButton _gap" v-if="meta && meta.enableTwitterIntegration" :href="`${apiUrl}/signin/twitter`"><i class="fab fa-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a> - <a class="_borderButton _gap" v-if="meta && meta.enableGithubIntegration" :href="`${apiUrl}/signin/github`"><i class="fab fa-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a> - <a class="_borderButton _gap" v-if="meta && meta.enableDiscordIntegration" :href="`${apiUrl}/signin/discord`"><i class="fab fa-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a> - </div> -</form> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { toUnicode } from 'punycode/'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import { apiUrl, host } from '@client/config'; -import { byteify, hexify } from '@client/scripts/2fa'; -import * as os from '@client/os'; -import { login } from '@client/account'; -import { showSuspendedDialog } from '../scripts/show-suspended-dialog'; - -export default defineComponent({ - components: { - MkButton, - MkInput, - }, - - props: { - withAvatar: { - type: Boolean, - required: false, - default: true - }, - autoSet: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['login'], - - data() { - return { - signing: false, - user: null, - username: '', - password: '', - token: '', - apiUrl, - host: toUnicode(host), - totpLogin: false, - credential: null, - challengeData: null, - queryingKey: false, - }; - }, - - computed: { - meta() { - return this.$instance; - }, - }, - - methods: { - onUsernameChange() { - os.api('users/show', { - username: this.username - }).then(user => { - this.user = user; - }, () => { - this.user = null; - }); - }, - - onLogin(res) { - if (this.autoSet) { - return login(res.i); - } else { - return; - } - }, - - queryKey() { - this.queryingKey = true; - return navigator.credentials.get({ - publicKey: { - challenge: byteify(this.challengeData.challenge, 'base64'), - allowCredentials: this.challengeData.securityKeys.map(key => ({ - id: byteify(key.id, 'hex'), - type: 'public-key', - transports: ['usb', 'nfc', 'ble', 'internal'] - })), - timeout: 60 * 1000 - } - }).catch(() => { - this.queryingKey = false; - return Promise.reject(null); - }).then(credential => { - this.queryingKey = false; - this.signing = true; - return os.api('signin', { - username: this.username, - password: this.password, - signature: hexify(credential.response.signature), - authenticatorData: hexify(credential.response.authenticatorData), - clientDataJSON: hexify(credential.response.clientDataJSON), - credentialId: credential.id, - challengeId: this.challengeData.challengeId - }); - }).then(res => { - this.$emit('login', res); - return this.onLogin(res); - }).catch(err => { - if (err === null) return; - os.dialog({ - type: 'error', - text: this.$ts.signinFailed - }); - this.signing = false; - }); - }, - - onSubmit() { - this.signing = true; - if (!this.totpLogin && this.user && this.user.twoFactorEnabled) { - if (window.PublicKeyCredential && this.user.securityKeys) { - os.api('signin', { - username: this.username, - password: this.password - }).then(res => { - this.totpLogin = true; - this.signing = false; - this.challengeData = res; - return this.queryKey(); - }).catch(this.loginFailed); - } else { - this.totpLogin = true; - this.signing = false; - } - } else { - os.api('signin', { - username: this.username, - password: this.password, - token: this.user && this.user.twoFactorEnabled ? this.token : undefined - }).then(res => { - this.$emit('login', res); - this.onLogin(res); - }).catch(this.loginFailed); - } - }, - - loginFailed(err) { - switch (err.id) { - case '6cc579cc-885d-43d8-95c2-b8c7fc963280': { - os.dialog({ - type: 'error', - title: this.$ts.loginFailed, - text: this.$ts.noSuchUser - }); - break; - } - case 'e03a5f46-d309-4865-9b69-56282d94e1eb': { - showSuspendedDialog(); - break; - } - default: { - os.dialog({ - type: 'error', - title: this.$ts.loginFailed, - text: JSON.stringify(err) - }); - } - } - - this.challengeData = null; - this.totpLogin = false; - this.signing = false; - }, - - resetPassword() { - os.popup(import('@client/components/forgot-password.vue'), {}, { - }, 'closed'); - } - } -}); -</script> - -<style lang="scss" scoped> -.eppvobhk { - > .auth { - > .avatar { - margin: 0 auto 0 auto; - width: 64px; - height: 64px; - background: #ddd; - background-position: center; - background-size: cover; - border-radius: 100%; - } - } -} -</style> diff --git a/src/client/components/signup-dialog.vue b/src/client/components/signup-dialog.vue deleted file mode 100644 index 9741e8c73b..0000000000 --- a/src/client/components/signup-dialog.vue +++ /dev/null @@ -1,50 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="366" - :height="500" - @close="$refs.dialog.close()" - @closed="$emit('closed')" -> - <template #header>{{ $ts.signup }}</template> - - <div class="_monolithic_"> - <div class="_section"> - <XSignup :auto-set="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> - </div> - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import XSignup from './signup.vue'; - -export default defineComponent({ - components: { - XSignup, - XModalWindow, - }, - - props: { - autoSet: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['done', 'closed'], - - methods: { - onSignup(res) { - this.$emit('done', res); - this.$refs.dialog.close(); - }, - - onSignupEmailPending() { - this.$refs.dialog.close(); - } - } -}); -</script> diff --git a/src/client/components/signup.vue b/src/client/components/signup.vue deleted file mode 100644 index 8d4340fd36..0000000000 --- a/src/client/components/signup.vue +++ /dev/null @@ -1,268 +0,0 @@ -<template> -<form class="qlvuhzng _formRoot" @submit.prevent="onSubmit" :autocomplete="Math.random()"> - <template v-if="meta"> - <MkInput class="_formBlock" v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required> - <template #label>{{ $ts.invitationCode }}</template> - <template #prefix><i class="fas fa-key"></i></template> - </MkInput> - <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeUsername" data-cy-signup-username> - <template #label>{{ $ts.username }} <div class="_button _help" v-tooltip:dialog="$ts.usernameInfo"><i class="far fa-question-circle"></i></div></template> - <template #prefix>@</template> - <template #suffix>@{{ host }}</template> - <template #caption> - <span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> - <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> - <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> - <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> - <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span> - <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span> - <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span> - </template> - </MkInput> - <MkInput v-if="meta.emailRequiredForSignup" class="_formBlock" v-model="email" :debounce="true" type="email" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeEmail" data-cy-signup-email> - <template #label>{{ $ts.emailAddress }} <div class="_button _help" v-tooltip:dialog="$ts._signup.emailAddressInfo"><i class="far fa-question-circle"></i></div></template> - <template #prefix><i class="fas fa-envelope"></i></template> - <template #caption> - <span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span> - <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span> - <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span> - <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span> - <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span> - <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span> - <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</span> - <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span> - <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span> - </template> - </MkInput> - <MkInput class="_formBlock" v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password> - <template #label>{{ $ts.password }}</template> - <template #prefix><i class="fas fa-lock"></i></template> - <template #caption> - <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span> - <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span> - <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span> - </template> - </MkInput> - <MkInput class="_formBlock" v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePasswordRetype" data-cy-signup-password-retype> - <template #label>{{ $ts.password }} ({{ $ts.retype }})</template> - <template #prefix><i class="fas fa-lock"></i></template> - <template #caption> - <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span> - <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span> - </template> - </MkInput> - <label v-if="meta.tosUrl" class="_formBlock tou"> - <input type="checkbox" v-model="ToSAgreement"> - <I18n :src="$ts.agreeTo"> - <template #0> - <a :href="meta.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a> - </template> - </I18n> - </label> - <captcha v-if="meta.enableHcaptcha" class="_formBlock captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/> - <captcha v-if="meta.enableRecaptcha" class="_formBlock captcha" provider="recaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/> - <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton> - </template> -</form> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -const getPasswordStrength = require('syuilo-password-strength'); -import { toUnicode } from 'punycode/'; -import { host, url } from '@client/config'; -import MkButton from './ui/button.vue'; -import MkInput from './form/input.vue'; -import MkSwitch from './form/switch.vue'; -import * as os from '@client/os'; -import { login } from '@client/account'; - -export default defineComponent({ - components: { - MkButton, - MkInput, - MkSwitch, - captcha: defineAsyncComponent(() => import('./captcha.vue')), - }, - - props: { - autoSet: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['signup'], - - data() { - return { - host: toUnicode(host), - username: '', - password: '', - retypedPassword: '', - invitationCode: '', - email: '', - url, - usernameState: null, - emailState: null, - passwordStrength: '', - passwordRetypeState: null, - submitting: false, - ToSAgreement: false, - hCaptchaResponse: null, - reCaptchaResponse: null, - } - }, - - computed: { - meta() { - return this.$instance; - }, - - shouldDisableSubmitting(): boolean { - return this.submitting || - this.meta.tosUrl && !this.ToSAgreement || - this.meta.enableHcaptcha && !this.hCaptchaResponse || - this.meta.enableRecaptcha && !this.reCaptchaResponse || - this.passwordRetypeState == 'not-match'; - }, - - shouldShowProfileUrl(): boolean { - return (this.username != '' && - this.usernameState != 'invalid-format' && - this.usernameState != 'min-range' && - this.usernameState != 'max-range'); - } - }, - - methods: { - onChangeUsername() { - if (this.username == '') { - this.usernameState = null; - return; - } - - const err = - !this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : - this.username.length < 1 ? 'min-range' : - this.username.length > 20 ? 'max-range' : - null; - - if (err) { - this.usernameState = err; - return; - } - - this.usernameState = 'wait'; - - os.api('username/available', { - username: this.username - }).then(result => { - this.usernameState = result.available ? 'ok' : 'unavailable'; - }).catch(err => { - this.usernameState = 'error'; - }); - }, - - onChangeEmail() { - if (this.email == '') { - this.emailState = null; - return; - } - - this.emailState = 'wait'; - - os.api('email-address/available', { - emailAddress: this.email - }).then(result => { - this.emailState = result.available ? 'ok' : - result.reason === 'used' ? 'unavailable:used' : - result.reason === 'format' ? 'unavailable:format' : - result.reason === 'disposable' ? 'unavailable:disposable' : - result.reason === 'mx' ? 'unavailable:mx' : - result.reason === 'smtp' ? 'unavailable:smtp' : - 'unavailable'; - }).catch(err => { - this.emailState = 'error'; - }); - }, - - onChangePassword() { - if (this.password == '') { - this.passwordStrength = ''; - return; - } - - const strength = getPasswordStrength(this.password); - this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; - }, - - onChangePasswordRetype() { - if (this.retypedPassword == '') { - this.passwordRetypeState = null; - return; - } - - this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match'; - }, - - onSubmit() { - if (this.submitting) return; - this.submitting = true; - - os.api('signup', { - username: this.username, - password: this.password, - emailAddress: this.email, - invitationCode: this.invitationCode, - 'hcaptcha-response': this.hCaptchaResponse, - 'g-recaptcha-response': this.reCaptchaResponse, - }).then(() => { - if (this.meta.emailRequiredForSignup) { - os.dialog({ - type: 'success', - title: this.$ts._signup.almostThere, - text: this.$t('_signup.emailSent', { email: this.email }), - }); - this.$emit('signupEmailPending'); - } else { - os.api('signin', { - username: this.username, - password: this.password - }).then(res => { - this.$emit('signup', res); - - if (this.autoSet) { - login(res.i); - } - }); - } - }).catch(() => { - this.submitting = false; - this.$refs.hcaptcha?.reset?.(); - this.$refs.recaptcha?.reset?.(); - - os.dialog({ - type: 'error', - text: this.$ts.somethingHappened - }); - }); - } - } -}); -</script> - -<style lang="scss" scoped> -.qlvuhzng { - .captcha { - margin: 16px 0; - } - - > .tou { - display: block; - margin: 16px 0; - cursor: pointer; - } -} -</style> diff --git a/src/client/components/sparkle.vue b/src/client/components/sparkle.vue deleted file mode 100644 index 942412b445..0000000000 --- a/src/client/components/sparkle.vue +++ /dev/null @@ -1,180 +0,0 @@ -<template> -<span class="mk-sparkle"> - <span ref="content"> - <slot></slot> - </span> - <canvas ref="canvas"></canvas> -</span> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -const sprite = new Image(); -sprite.src = "/static-assets/client/sparkle-spritesheet.png"; - - -export default defineComponent({ - props: { - count: { - type: Number, - required: true, - }, - speed: { - type: Number, - required: true, - }, - }, - data() { - return { - sprites: [0,6,13,20], - particles: [], - anim: null, - ctx: null, - }; - }, - methods: { - createSparkles(w, h, count) { - var holder = []; - - for (var i = 0; i < count; i++) { - - const color = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6); - - holder[i] = { - position: { - x: Math.floor(Math.random() * w), - y: Math.floor(Math.random() * h) - }, - style: this.sprites[ Math.floor(Math.random() * 4) ], - delta: { - x: Math.floor(Math.random() * 1000) - 500, - y: Math.floor(Math.random() * 1000) - 500 - }, - color: color, - opacity: Math.random(), - }; - - } - - return holder; - }, - draw(time) { - this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height); - this.ctx.beginPath(); - - const particleSize = Math.floor(this.fontSize / 2); - this.particles.forEach((particle) => { - var modulus = Math.floor(Math.random()*7); - - if (Math.floor(time) % modulus === 0) { - particle.style = this.sprites[ Math.floor(Math.random()*4) ]; - } - - this.ctx.save(); - this.ctx.globalAlpha = particle.opacity; - this.ctx.drawImage(sprite, particle.style, 0, 7, 7, particle.position.x, particle.position.y, particleSize, particleSize); - - this.ctx.globalCompositeOperation = "source-atop"; - this.ctx.globalAlpha = 0.5; - this.ctx.fillStyle = particle.color; - this.ctx.fillRect(particle.position.x, particle.position.y, particleSize, particleSize); - - this.ctx.restore(); - }); - this.ctx.stroke(); - }, - tick() { - this.anim = window.requestAnimationFrame((time) => { - if (!this.$refs.canvas) { - return; - } - this.particles.forEach((particle) => { - if (!particle) { - return; - } - var randX = Math.random() > Math.random() * 2; - var randY = Math.random() > Math.random() * 3; - - if (randX) { - particle.position.x += (particle.delta.x * this.speed) / 1500; - } - - if (!randY) { - particle.position.y -= (particle.delta.y * this.speed) / 800; - } - - if( particle.position.x > this.$refs.canvas.width ) { - particle.position.x = -7; - } else if (particle.position.x < -7) { - particle.position.x = this.$refs.canvas.width; - } - - if (particle.position.y > this.$refs.canvas.height) { - particle.position.y = -7; - particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width); - } else if (particle.position.y < -7) { - particle.position.y = this.$refs.canvas.height; - particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width); - } - - particle.opacity -= 0.005; - - if (particle.opacity <= 0) { - particle.opacity = 1; - } - }); - - this.draw(time); - - this.tick(); - }); - }, - resize() { - if (this.$refs.content) { - const contentRect = this.$refs.content.getBoundingClientRect(); - this.fontSize = parseFloat(getComputedStyle(this.$refs.content).fontSize); - const padding = this.fontSize * 0.2; - - this.$refs.canvas.width = parseInt(contentRect.width + padding); - this.$refs.canvas.height = parseInt(contentRect.height + padding); - - this.particles = this.createSparkles(this.$refs.canvas.width, this.$refs.canvas.height, this.count); - } - }, - }, - mounted() { - this.ctx = this.$refs.canvas.getContext('2d'); - - new ResizeObserver(this.resize).observe(this.$refs.content); - - this.resize(); - this.tick(); - }, - updated() { - this.resize(); - }, - destroyed() { - window.cancelAnimationFrame(this.anim); - }, -}); -</script> - -<style lang="scss" scoped> -.mk-sparkle { - position: relative; - display: inline-block; - - > span { - display: inline-block; - } - - > canvas { - position: absolute; - top: -0.1em; - left: -0.1em; - pointer-events: none; - } -} -</style> diff --git a/src/client/components/sub-note-content.vue b/src/client/components/sub-note-content.vue deleted file mode 100644 index ff89a9887b..0000000000 --- a/src/client/components/sub-note-content.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<div class="wrmlmaau"> - <div class="body"> - <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> - <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> - <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> - </div> - <details v-if="note.files.length > 0"> - <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> - <XMediaList :media-list="note.files"/> - </details> - <details v-if="note.poll"> - <summary>{{ $ts.poll }}</summary> - <XPoll :note="note"/> - </details> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XPoll from './poll.vue'; -import XMediaList from './media-list.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XPoll, - XMediaList, - }, - props: { - note: { - type: Object, - required: true - } - }, - data() { - return { - }; - } -}); -</script> - -<style lang="scss" scoped> -.wrmlmaau { - overflow-wrap: break-word; - - > .body { - > .reply { - margin-right: 6px; - color: var(--accent); - } - - > .rp { - margin-left: 4px; - font-style: oblique; - color: var(--renote); - } - } -} -</style> diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue deleted file mode 100644 index c629727358..0000000000 --- a/src/client/components/tab.vue +++ /dev/null @@ -1,73 +0,0 @@ -<script lang="ts"> -import { defineComponent, h, resolveDirective, withDirectives } from 'vue'; - -export default defineComponent({ - props: { - modelValue: { - required: true, - }, - }, - render() { - const options = this.$slots.default(); - - return withDirectives(h('div', { - class: 'pxhvhrfw', - }, options.map(option => withDirectives(h('button', { - class: ['_button', { active: this.modelValue === option.props.value }], - key: option.key, - disabled: this.modelValue === option.props.value, - onClick: () => { - this.$emit('update:modelValue', option.props.value); - } - }, option.children), [ - [resolveDirective('click-anime')] - ]))), [ - [resolveDirective('size'), { max: [500] }] - ]); - } -}); -</script> - -<style lang="scss"> -.pxhvhrfw { - display: flex; - font-size: 90%; - - > button { - flex: 1; - padding: 10px 8px; - border-radius: var(--radius); - - &:disabled { - opacity: 1 !important; - cursor: default; - } - - &.active { - color: var(--accent); - background: var(--accentedBg); - } - - &:not(.active):hover { - color: var(--fgHighlighted); - background: var(--panelHighlight); - } - - &:not(:first-child) { - margin-left: 8px; - } - - > .icon { - margin-right: 6px; - } - } - - &.max-width_500px { - font-size: 80%; - - > button { - padding: 11px 8px; - } - } -} -</style> diff --git a/src/client/components/taskmanager.api-window.vue b/src/client/components/taskmanager.api-window.vue deleted file mode 100644 index 807e4a0075..0000000000 --- a/src/client/components/taskmanager.api-window.vue +++ /dev/null @@ -1,72 +0,0 @@ -<template> -<XWindow ref="window" - :initial-width="370" - :initial-height="450" - :can-resize="true" - @close="$refs.window.close()" - @closed="$emit('closed')" -> - <template #header>Req Viewer</template> - - <div class="rlkneywz"> - <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> - <option value="req">Request</option> - <option value="res">Response</option> - </MkTab> - - <code v-if="tab === 'req'" class="_monospace">{{ reqStr }}</code> - <code v-if="tab === 'res'" class="_monospace">{{ resStr }}</code> - </div> -</XWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as JSON5 from 'json5'; -import XWindow from '@client/components/ui/window.vue'; -import MkTab from '@client/components/tab.vue'; - -export default defineComponent({ - components: { - XWindow, - MkTab, - }, - - props: { - req: { - required: true, - } - }, - - emits: ['closed'], - - data() { - return { - tab: 'req', - reqStr: JSON5.stringify(this.req.req, null, '\t'), - resStr: JSON5.stringify(this.req.res, null, '\t'), - } - }, - - methods: { - } -}); -</script> - -<style lang="scss" scoped> -.rlkneywz { - display: flex; - flex-direction: column; - height: 100%; - - > code { - display: block; - flex: 1; - padding: 8px; - overflow: auto; - font-size: 0.9em; - tab-size: 2; - white-space: pre; - } -} -</style> diff --git a/src/client/components/taskmanager.vue b/src/client/components/taskmanager.vue deleted file mode 100644 index 6f3d1b0354..0000000000 --- a/src/client/components/taskmanager.vue +++ /dev/null @@ -1,233 +0,0 @@ -<template> -<XWindow ref="window" :initial-width="650" :initial-height="420" :can-resize="true" @closed="$emit('closed')"> - <template #header> - <i class="fas fa-terminal" style="margin-right: 0.5em;"></i>Task Manager - </template> - <div class="qljqmnzj _monospace"> - <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);"> - <option value="windows">Windows</option> - <option value="stream">Stream</option> - <option value="streamPool">Stream (Pool)</option> - <option value="api">API</option> - </MkTab> - - <div class="content"> - <div v-if="tab === 'windows'" class="windows" v-follow> - <div class="header"> - <div>#ID</div> - <div>Component</div> - <div>Action</div> - </div> - <div v-for="p in popups"> - <div>#{{ p.id }}</div> - <div>{{ p.component.name ? p.component.name : '<anonymous>' }}</div> - <div><button class="_textButton" @click="killPopup(p)">Kill</button></div> - </div> - </div> - <div v-if="tab === 'stream'" class="stream" v-follow> - <div class="header"> - <div>#ID</div> - <div>Ch</div> - <div>Handle</div> - <div>In</div> - <div>Out</div> - </div> - <div v-for="c in connections"> - <div>#{{ c.id }}</div> - <div>{{ c.channel }}</div> - <div v-if="c.users !== null">(shared)<span v-if="c.name">{{ ' ' + c.name }}</span></div> - <div v-else>{{ c.name ? c.name : '<anonymous>' }}</div> - <div>{{ c.in }}</div> - <div>{{ c.out }}</div> - </div> - </div> - <div v-if="tab === 'streamPool'" class="streamPool" v-follow> - <div class="header"> - <div>#ID</div> - <div>Ch</div> - <div>Users</div> - </div> - <div v-for="p in pools"> - <div>#{{ p.id }}</div> - <div>{{ p.channel }}</div> - <div>{{ p.users }}</div> - </div> - </div> - <div v-if="tab === 'api'" class="api" v-follow> - <div class="header"> - <div>#ID</div> - <div>Endpoint</div> - <div>State</div> - </div> - <div v-for="req in apiRequests" @click="showReq(req)"> - <div>#{{ req.id }}</div> - <div>{{ req.endpoint }}</div> - <div class="state" :class="req.state">{{ req.state }}</div> - </div> - </div> - </div> - - <footer> - <div><span class="label">Windows</span>{{ popups.length }}</div> - <div><span class="label">Stream</span>{{ connections.length }}</div> - <div><span class="label">Stream (Pool)</span>{{ pools.length }}</div> - </footer> - </div> -</XWindow> -</template> - -<script lang="ts"> -import { defineComponent, markRaw, onBeforeUnmount, ref, shallowRef } from 'vue'; -import XWindow from '@client/components/ui/window.vue'; -import MkTab from '@client/components/tab.vue'; -import MkButton from '@client/components/ui/button.vue'; -import follow from '@client/directives/follow-append'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XWindow, - MkTab, - MkButton, - }, - - directives: { - follow - }, - - props: { - }, - - emits: ['closed'], - - setup() { - const connections = shallowRef([]); - const pools = shallowRef([]); - const refreshStreamInfo = () => { - console.log(os.stream.sharedConnectionPools, os.stream.sharedConnections, os.stream.nonSharedConnections); - const conn = os.stream.sharedConnections.map(c => ({ - id: c.id, name: c.name, channel: c.channel, users: c.pool.users, in: c.inCount, out: c.outCount, - })).concat(os.stream.nonSharedConnections.map(c => ({ - id: c.id, name: c.name, channel: c.channel, users: null, in: c.inCount, out: c.outCount, - }))); - conn.sort((a, b) => (a.id > b.id) ? 1 : -1); - connections.value = conn; - pools.value = os.stream.sharedConnectionPools; - }; - const interval = setInterval(refreshStreamInfo, 1000); - onBeforeUnmount(() => { - clearInterval(interval); - }); - - const killPopup = p => { - os.popups.value = os.popups.value.filter(x => x !== p); - }; - - const showReq = req => { - os.popup(import('./taskmanager.api-window.vue'), { - req: req - }, { - }, 'closed'); - }; - - return { - tab: ref('stream'), - popups: os.popups, - apiRequests: os.apiRequests, - connections, - pools, - killPopup, - showReq, - }; - }, -}); -</script> - -<style lang="scss" scoped> -.qljqmnzj { - display: flex; - flex-direction: column; - height: 100%; - - > .content { - flex: 1; - overflow: auto; - - > div { - display: table; - width: 100%; - padding: 16px; - box-sizing: border-box; - - > div { - display: table-row; - - &:nth-child(even) { - //background: rgba(0, 0, 0, 0.1); - } - - &.header { - opacity: 0.7; - } - - > div { - display: table-cell; - white-space: nowrap; - - &:not(:last-child) { - padding-right: 8px; - } - } - } - - &.api { - > div { - &:not(.header) { - cursor: pointer; - - &:hover { - color: var(--accent); - } - } - - > .state { - &.pending { - color: var(--warn); - } - - &.success { - color: var(--success); - } - - &.failed { - color: var(--error); - } - } - } - } - } - } - - > footer { - display: flex; - width: 100%; - padding: 8px 16px; - box-sizing: border-box; - border-top: solid 0.5px var(--divider); - font-size: 0.9em; - - > div { - flex: 1; - - > .label { - opacity: 0.7; - margin-right: 0.5em; - - &:after { - content: ":"; - } - } - } - } -} -</style> diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue deleted file mode 100644 index 9676616f2a..0000000000 --- a/src/client/components/timeline.vue +++ /dev/null @@ -1,183 +0,0 @@ -<template> -<XNotes :no-gap="!$store.state.showGapBetweenNotesInTimeline" ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/> -</template> - -<script lang="ts"> -import { defineComponent, markRaw } from 'vue'; -import XNotes from './notes.vue'; -import * as os from '@client/os'; -import * as sound from '@client/scripts/sound'; - -export default defineComponent({ - components: { - XNotes - }, - - provide() { - return { - inChannel: this.src === 'channel' - }; - }, - - props: { - src: { - type: String, - required: true - }, - list: { - type: String, - required: false - }, - antenna: { - type: String, - required: false - }, - channel: { - type: String, - required: false - }, - sound: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['note', 'queue', 'before', 'after'], - - data() { - return { - connection: null, - connection2: null, - pagination: null, - baseQuery: { - includeMyRenotes: this.$store.state.showMyRenotes, - includeRenotedMyNotes: this.$store.state.showRenotedMyNotes, - includeLocalRenotes: this.$store.state.showLocalRenotes - }, - query: {}, - date: null - }; - }, - - created() { - const prepend = note => { - (this.$refs.tl as any).prepend(note); - - this.$emit('note'); - - if (this.sound) { - sound.play(note.userId === this.$i.id ? 'noteMy' : 'note'); - } - }; - - const onUserAdded = () => { - (this.$refs.tl as any).reload(); - }; - - const onUserRemoved = () => { - (this.$refs.tl as any).reload(); - }; - - const onChangeFollowing = () => { - if (!this.$refs.tl.backed) { - this.$refs.tl.reload(); - } - }; - - let endpoint; - - if (this.src == 'antenna') { - endpoint = 'antennas/notes'; - this.query = { - antennaId: this.antenna - }; - this.connection = markRaw(os.stream.useChannel('antenna', { - antennaId: this.antenna - })); - this.connection.on('note', prepend); - } else if (this.src == 'home') { - endpoint = 'notes/timeline'; - this.connection = markRaw(os.stream.useChannel('homeTimeline')); - this.connection.on('note', prepend); - - this.connection2 = markRaw(os.stream.useChannel('main')); - this.connection2.on('follow', onChangeFollowing); - this.connection2.on('unfollow', onChangeFollowing); - } else if (this.src == 'local') { - endpoint = 'notes/local-timeline'; - this.connection = markRaw(os.stream.useChannel('localTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'social') { - endpoint = 'notes/hybrid-timeline'; - this.connection = markRaw(os.stream.useChannel('hybridTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'global') { - endpoint = 'notes/global-timeline'; - this.connection = markRaw(os.stream.useChannel('globalTimeline')); - this.connection.on('note', prepend); - } else if (this.src == 'mentions') { - endpoint = 'notes/mentions'; - this.connection = markRaw(os.stream.useChannel('main')); - this.connection.on('mention', prepend); - } else if (this.src == 'directs') { - endpoint = 'notes/mentions'; - this.query = { - visibility: 'specified' - }; - const onNote = note => { - if (note.visibility == 'specified') { - prepend(note); - } - }; - this.connection = markRaw(os.stream.useChannel('main')); - this.connection.on('mention', onNote); - } else if (this.src == 'list') { - endpoint = 'notes/user-list-timeline'; - this.query = { - listId: this.list - }; - this.connection = markRaw(os.stream.useChannel('userList', { - listId: this.list - })); - this.connection.on('note', prepend); - this.connection.on('userAdded', onUserAdded); - this.connection.on('userRemoved', onUserRemoved); - } else if (this.src == 'channel') { - endpoint = 'channels/timeline'; - this.query = { - channelId: this.channel - }; - this.connection = markRaw(os.stream.useChannel('channel', { - channelId: this.channel - })); - this.connection.on('note', prepend); - } - - this.pagination = { - endpoint: endpoint, - limit: 10, - params: init => ({ - untilDate: this.date?.getTime(), - ...this.baseQuery, ...this.query - }) - }; - }, - - beforeUnmount() { - this.connection.dispose(); - if (this.connection2) this.connection2.dispose(); - }, - - methods: { - focus() { - this.$refs.tl.focus(); - }, - - timetravel(date?: Date) { - this.date = date; - this.$refs.tl.reload(); - } - } -}); -</script> diff --git a/src/client/components/toast.vue b/src/client/components/toast.vue deleted file mode 100644 index fb0de68092..0000000000 --- a/src/client/components/toast.vue +++ /dev/null @@ -1,73 +0,0 @@ -<template> -<div class="mk-toast"> - <transition name="notification-slide" appear @after-leave="$emit('closed')"> - <XNotification :notification="notification" class="notification _acrylic" v-if="showing"/> - </transition> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XNotification from './notification.vue'; - -export default defineComponent({ - components: { - XNotification - }, - props: { - notification: { - type: Object, - required: true - } - }, - emits: ['closed'], - data() { - return { - showing: true - }; - }, - mounted() { - setTimeout(() => { - this.showing = false; - }, 6000); - } -}); -</script> - -<style lang="scss" scoped> -.notification-slide-enter-active, .notification-slide-leave-active { - transition: opacity 0.3s, transform 0.3s !important; -} -.notification-slide-enter-from, .notification-slide-leave-to { - opacity: 0; - transform: translateX(-250px); -} - -.mk-toast { - position: fixed; - z-index: 10000; - left: 0; - width: 250px; - top: 32px; - padding: 0 32px; - pointer-events: none; - - @media (max-width: 700px) { - top: initial; - bottom: 112px; - padding: 0 16px; - } - - @media (max-width: 500px) { - bottom: 92px; - padding: 0 8px; - } - - > .notification { - height: 100%; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); - border-radius: 8px; - overflow: hidden; - } -} -</style> diff --git a/src/client/components/token-generate-window.vue b/src/client/components/token-generate-window.vue deleted file mode 100644 index 86312564cc..0000000000 --- a/src/client/components/token-generate-window.vue +++ /dev/null @@ -1,117 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="400" - :height="450" - :with-ok-button="true" - :ok-button-disabled="false" - :can-close="false" - @close="$refs.dialog.close()" - @closed="$emit('closed')" - @ok="ok()" -> - <template #header>{{ title || $ts.generateAccessToken }}</template> - <div v-if="information" class="_section"> - <MkInfo warn>{{ information }}</MkInfo> - </div> - <div class="_section"> - <MkInput v-model="name"> - <template #label>{{ $ts.name }}</template> - </MkInput> - </div> - <div class="_section"> - <div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div> - <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> - <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> - <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch> - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { kinds } from '@/misc/api-permissions'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import MkInput from './form/input.vue'; -import MkTextarea from './form/textarea.vue'; -import MkSwitch from './form/switch.vue'; -import MkButton from './ui/button.vue'; -import MkInfo from './ui/info.vue'; - -export default defineComponent({ - components: { - XModalWindow, - MkInput, - MkTextarea, - MkSwitch, - MkButton, - MkInfo, - }, - - props: { - title: { - type: String, - required: false, - default: null - }, - information: { - type: String, - required: false, - default: null - }, - initialName: { - type: String, - required: false, - default: null - }, - initialPermissions: { - type: Array, - required: false, - default: null - } - }, - - emits: ['done', 'closed'], - - data() { - return { - name: this.initialName, - permissions: {}, - kinds - }; - }, - - created() { - if (this.initialPermissions) { - for (const kind of this.initialPermissions) { - this.permissions[kind] = true; - } - } else { - for (const kind of this.kinds) { - this.permissions[kind] = false; - } - } - }, - - methods: { - ok() { - this.$emit('done', { - name: this.name, - permissions: Object.keys(this.permissions).filter(p => this.permissions[p]) - }); - this.$refs.dialog.close(); - }, - - disableAll() { - for (const p in this.permissions) { - this.permissions[p] = false; - } - }, - - enableAll() { - for (const p in this.permissions) { - this.permissions[p] = true; - } - } - } -}); -</script> diff --git a/src/client/components/ui/button.vue b/src/client/components/ui/button.vue deleted file mode 100644 index b5f4547c84..0000000000 --- a/src/client/components/ui/button.vue +++ /dev/null @@ -1,262 +0,0 @@ -<template> -<button v-if="!link" class="bghgjjyj _button" - :class="{ inline, primary, gradate, danger, rounded, full }" - :type="type" - @click="$emit('click', $event)" - @mousedown="onMousedown" -> - <div ref="ripples" class="ripples"></div> - <div class="content"> - <slot></slot> - </div> -</button> -<MkA v-else class="bghgjjyj _button" - :class="{ inline, primary, gradate, danger, rounded, full }" - :to="to" - @mousedown="onMousedown" -> - <div ref="ripples" class="ripples"></div> - <div class="content"> - <slot></slot> - </div> -</MkA> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - type: { - type: String, - required: false - }, - primary: { - type: Boolean, - required: false, - default: false - }, - gradate: { - type: Boolean, - required: false, - default: false - }, - rounded: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - link: { - type: Boolean, - required: false, - default: false - }, - to: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - wait: { - type: Boolean, - required: false, - default: false - }, - danger: { - type: Boolean, - required: false, - default: false - }, - full: { - type: Boolean, - required: false, - default: false - }, - }, - emits: ['click'], - mounted() { - if (this.autofocus) { - this.$nextTick(() => { - this.$el.focus(); - }); - } - }, - methods: { - onMousedown(e: MouseEvent) { - function distance(p, q) { - return Math.hypot(p.x - q.x, p.y - q.y); - } - - function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) { - const origin = {x: circleCenterX, y: circleCenterY}; - const dist1 = distance({x: 0, y: 0}, origin); - const dist2 = distance({x: boxW, y: 0}, origin); - const dist3 = distance({x: 0, y: boxH}, origin); - const dist4 = distance({x: boxW, y: boxH }, origin); - return Math.max(dist1, dist2, dist3, dist4) * 2; - } - - const rect = e.target.getBoundingClientRect(); - - const ripple = document.createElement('div'); - ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px'; - ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px'; - - this.$refs.ripples.appendChild(ripple); - - const circleCenterX = e.clientX - rect.left; - const circleCenterY = e.clientY - rect.top; - - const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); - - setTimeout(() => { - ripple.style.transform = 'scale(' + (scale / 2) + ')'; - }, 1); - setTimeout(() => { - ripple.style.transition = 'all 1s ease'; - ripple.style.opacity = '0'; - }, 1000); - setTimeout(() => { - if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); - }, 2000); - } - } -}); -</script> - -<style lang="scss" scoped> -.bghgjjyj { - position: relative; - z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため - display: block; - min-width: 100px; - width: max-content; - padding: 8px 14px; - text-align: center; - font-weight: normal; - font-size: 0.8em; - line-height: 22px; - box-shadow: none; - text-decoration: none; - background: var(--buttonBg); - border-radius: 4px; - overflow: clip; - box-sizing: border-box; - transition: background 0.1s ease; - - &:not(:disabled):hover { - background: var(--buttonHoverBg); - } - - &:not(:disabled):active { - background: var(--buttonHoverBg); - } - - &.full { - width: 100%; - } - - &.rounded { - border-radius: 999px; - } - - &.primary { - font-weight: bold; - color: var(--fgOnAccent) !important; - background: var(--accent); - - &:not(:disabled):hover { - background: var(--X8); - } - - &:not(:disabled):active { - background: var(--X8); - } - } - - &.gradate { - font-weight: bold; - color: var(--fgOnAccent) !important; - background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); - - &:not(:disabled):hover { - background: linear-gradient(90deg, var(--X8), var(--X8)); - } - - &:not(:disabled):active { - background: linear-gradient(90deg, var(--X8), var(--X8)); - } - } - - &.danger { - color: #ff2a2a; - - &.primary { - color: #fff; - background: #ff2a2a; - - &:not(:disabled):hover { - background: #ff4242; - } - - &:not(:disabled):active { - background: #d42e2e; - } - } - } - - &:disabled { - opacity: 0.7; - } - - &:focus-visible { - outline: solid 2px var(--focus); - outline-offset: 2px; - } - - &.inline { - display: inline-block; - width: auto; - min-width: 100px; - } - - > .ripples { - position: absolute; - z-index: 0; - top: 0; - left: 0; - width: 100%; - height: 100%; - border-radius: 6px; - overflow: hidden; - - ::v-deep(div) { - position: absolute; - width: 2px; - height: 2px; - border-radius: 100%; - background: rgba(0, 0, 0, 0.1); - opacity: 1; - transform: scale(1); - transition: all 0.5s cubic-bezier(0,.5,0,1); - } - } - - &.primary > .ripples ::v-deep(div) { - background: rgba(0, 0, 0, 0.15); - } - - > .content { - position: relative; - z-index: 1; - } -} -</style> diff --git a/src/client/components/ui/container.vue b/src/client/components/ui/container.vue deleted file mode 100644 index 14673dfcd7..0000000000 --- a/src/client/components/ui/container.vue +++ /dev/null @@ -1,262 +0,0 @@ -<template> -<div class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }"> - <header v-if="showHeader" ref="header"> - <div class="title"><slot name="header"></slot></div> - <div class="sub"> - <slot name="func"></slot> - <button class="_button" v-if="foldable" @click="() => showBody = !showBody"> - <template v-if="showBody"><i class="fas fa-angle-up"></i></template> - <template v-else><i class="fas fa-angle-down"></i></template> - </button> - </div> - </header> - <transition name="container-toggle" - @enter="enter" - @after-enter="afterEnter" - @leave="leave" - @after-leave="afterLeave" - > - <div v-show="showBody" class="content" :class="{ omitted }" ref="content"> - <slot></slot> - <button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }"> - <span>{{ $ts.showMore }}</span> - </button> - </div> - </transition> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - showHeader: { - type: Boolean, - required: false, - default: true - }, - thin: { - type: Boolean, - required: false, - default: false - }, - naked: { - type: Boolean, - required: false, - default: false - }, - foldable: { - type: Boolean, - required: false, - default: false - }, - expanded: { - type: Boolean, - required: false, - default: true - }, - scrollable: { - type: Boolean, - required: false, - default: false - }, - maxHeight: { - type: Number, - required: false, - default: null - }, - }, - data() { - return { - showBody: this.expanded, - omitted: null, - ignoreOmit: false, - }; - }, - mounted() { - this.$watch('showBody', showBody => { - const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; - this.$el.style.minHeight = `${headerHeight}px`; - if (showBody) { - this.$el.style.flexBasis = `auto`; - } else { - this.$el.style.flexBasis = `${headerHeight}px`; - } - }, { - immediate: true - }); - - this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); - - const calcOmit = () => { - if (this.omitted || this.ignoreOmit || this.maxHeight == null) return; - const height = this.$refs.content.offsetHeight; - this.omitted = height > this.maxHeight; - }; - - calcOmit(); - new ResizeObserver((entries, observer) => { - calcOmit(); - }).observe(this.$refs.content); - }, - methods: { - toggleContent(show: boolean) { - if (!this.foldable) return; - this.showBody = show; - }, - - enter(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; - el.offsetHeight; // reflow - el.style.height = elementHeight + 'px'; - }, - afterEnter(el) { - el.style.height = null; - }, - leave(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = elementHeight + 'px'; - el.offsetHeight; // reflow - el.style.height = 0; - }, - afterLeave(el) { - el.style.height = null; - }, - } -}); -</script> - -<style lang="scss" scoped> -.container-toggle-enter-active, .container-toggle-leave-active { - overflow-y: hidden; - transition: opacity 0.5s, height 0.5s !important; -} -.container-toggle-enter-from { - opacity: 0; -} -.container-toggle-leave-to { - opacity: 0; -} - -.ukygtjoj { - position: relative; - overflow: clip; - - &.naked { - background: transparent !important; - box-shadow: none !important; - } - - &.scrollable { - display: flex; - flex-direction: column; - - > .content { - overflow: auto; - } - } - - > header { - position: sticky; - top: var(--stickyTop, 0px); - left: 0; - color: var(--panelHeaderFg); - background: var(--panelHeaderBg); - border-bottom: solid 0.5px var(--panelHeaderDivider); - z-index: 2; - line-height: 1.4em; - - > .title { - margin: 0; - padding: 12px 16px; - - > ::v-deep(i) { - margin-right: 6px; - } - - &:empty { - display: none; - } - } - - > .sub { - position: absolute; - z-index: 2; - top: 0; - right: 0; - height: 100%; - - > ::v-deep(button) { - width: 42px; - height: 100%; - } - } - } - - > .content { - --stickyTop: 0px; - - &.omitted { - position: relative; - max-height: var(--maxHeight); - overflow: hidden; - - > .fade { - display: block; - position: absolute; - z-index: 10; - bottom: 0; - left: 0; - width: 100%; - height: 64px; - background: linear-gradient(0deg, var(--panel), var(--X15)); - - > span { - display: inline-block; - background: var(--panel); - padding: 6px 10px; - font-size: 0.8em; - border-radius: 999px; - box-shadow: 0 2px 6px rgb(0 0 0 / 20%); - } - - &:hover { - > span { - background: var(--panelHighlight); - } - } - } - } - } - - &.max-width_380px, &.thin { - > header { - > .title { - padding: 8px 10px; - font-size: 0.9em; - } - } - - > .content { - } - } -} - -._forceContainerFull_ .ukygtjoj { - > header { - > .title { - padding: 12px 16px !important; - } - } -} - -._forceContainerFull_.ukygtjoj { - > header { - > .title { - padding: 12px 16px !important; - } - } -} -</style> diff --git a/src/client/components/ui/context-menu.vue b/src/client/components/ui/context-menu.vue deleted file mode 100644 index 61f5d3bf08..0000000000 --- a/src/client/components/ui/context-menu.vue +++ /dev/null @@ -1,97 +0,0 @@ -<template> -<transition :name="$store.state.animation ? 'fade' : ''" appear> - <div class="nvlagfpb" @contextmenu.prevent.stop="() => {}"> - <MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import contains from '@client/scripts/contains'; -import MkMenu from './menu.vue'; - -export default defineComponent({ - components: { - MkMenu, - }, - props: { - items: { - type: Array, - required: true - }, - ev: { - required: true - }, - viaKeyboard: { - type: Boolean, - required: false - }, - }, - emits: ['closed'], - computed: { - keymap(): any { - return { - 'esc': () => this.$emit('closed'), - }; - }, - }, - mounted() { - let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 - let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 - - const width = this.$el.offsetWidth; - const height = this.$el.offsetHeight; - - if (left + width - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - width + window.pageXOffset; - } - - if (top + height - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - height + window.pageYOffset; - } - - if (top < 0) { - top = 0; - } - - if (left < 0) { - left = 0; - } - - this.$el.style.top = top + 'px'; - this.$el.style.left = left + 'px'; - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - }, - beforeUnmount() { - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - }, - methods: { - onMousedown(e) { - if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed'); - }, - } -}); -</script> - -<style lang="scss" scoped> -.nvlagfpb { - position: absolute; - z-index: 65535; -} - -.fade-enter-active, .fade-leave-active { - transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); - transform-origin: left top; -} - -.fade-enter-from, .fade-leave-to { - opacity: 0; - transform: scale(0.9); -} -</style> diff --git a/src/client/components/ui/folder.vue b/src/client/components/ui/folder.vue deleted file mode 100644 index 3997421d08..0000000000 --- a/src/client/components/ui/folder.vue +++ /dev/null @@ -1,156 +0,0 @@ -<template> -<div class="ssazuxis" v-size="{ max: [500] }"> - <header @click="showBody = !showBody" class="_button" :style="{ background: bg }"> - <div class="title"><slot name="header"></slot></div> - <div class="divider"></div> - <button class="_button"> - <template v-if="showBody"><i class="fas fa-angle-up"></i></template> - <template v-else><i class="fas fa-angle-down"></i></template> - </button> - </header> - <transition name="folder-toggle" - @enter="enter" - @after-enter="afterEnter" - @leave="leave" - @after-leave="afterLeave" - > - <div v-show="showBody"> - <slot></slot> - </div> - </transition> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as tinycolor from 'tinycolor2'; - -const localStoragePrefix = 'ui:folder:'; - -export default defineComponent({ - props: { - expanded: { - type: Boolean, - required: false, - default: true - }, - persistKey: { - type: String, - required: false, - default: null - }, - }, - data() { - return { - bg: null, - showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded, - }; - }, - watch: { - showBody() { - if (this.persistKey) { - localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f'); - } - } - }, - mounted() { - function getParentBg(el: Element | null): string { - if (el == null || el.tagName === 'BODY') return 'var(--bg)'; - const bg = el.style.background || el.style.backgroundColor; - if (bg) { - return bg; - } else { - return getParentBg(el.parentElement); - } - } - const rawBg = getParentBg(this.$el); - const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); - bg.setAlpha(0.85); - this.bg = bg.toRgbString(); - }, - methods: { - toggleContent(show: boolean) { - this.showBody = show; - }, - - enter(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; - el.offsetHeight; // reflow - el.style.height = elementHeight + 'px'; - }, - afterEnter(el) { - el.style.height = null; - }, - leave(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = elementHeight + 'px'; - el.offsetHeight; // reflow - el.style.height = 0; - }, - afterLeave(el) { - el.style.height = null; - }, - } -}); -</script> - -<style lang="scss" scoped> -.folder-toggle-enter-active, .folder-toggle-leave-active { - overflow-y: hidden; - transition: opacity 0.5s, height 0.5s !important; -} -.folder-toggle-enter-from { - opacity: 0; -} -.folder-toggle-leave-to { - opacity: 0; -} - -.ssazuxis { - position: relative; - - > header { - display: flex; - position: relative; - z-index: 10; - position: sticky; - top: var(--stickyTop, 0px); - padding: var(--x-padding); - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(20px)); - - > .title { - margin: 0; - padding: 12px 16px 12px 0; - - > i { - margin-right: 6px; - } - - &:empty { - display: none; - } - } - - > .divider { - flex: 1; - margin: auto; - height: 1px; - background: var(--divider); - } - - > button { - padding: 12px 0 12px 16px; - } - } - - &.max-width_500px { - > header { - > .title { - padding: 8px 10px 8px 0; - } - } - } -} -</style> diff --git a/src/client/components/ui/hr.vue b/src/client/components/ui/hr.vue deleted file mode 100644 index fb12b4985f..0000000000 --- a/src/client/components/ui/hr.vue +++ /dev/null @@ -1,16 +0,0 @@ -<template> -<div class="evrzpitu"></div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue';import * as os from '@client/os'; - -export default defineComponent({}); -</script> - -<style lang="scss" scoped> -.evrzpitu - margin 16px 0 - border-bottom solid var(--lineWidth) var(--faceDivider) - -</style> diff --git a/src/client/components/ui/info.vue b/src/client/components/ui/info.vue deleted file mode 100644 index f6b2edf267..0000000000 --- a/src/client/components/ui/info.vue +++ /dev/null @@ -1,45 +0,0 @@ -<template> -<div class="fpezltsf" :class="{ warn }"> - <i v-if="warn" class="fas fa-exclamation-triangle"></i> - <i v-else class="fas fa-info-circle"></i> - <slot></slot> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - warn: { - type: Boolean, - required: false, - default: false - }, - }, - data() { - return { - }; - } -}); -</script> - -<style lang="scss" scoped> -.fpezltsf { - padding: 16px; - font-size: 90%; - background: var(--infoBg); - color: var(--infoFg); - border-radius: var(--radius); - - &.warn { - background: var(--infoWarnBg); - color: var(--infoWarnFg); - } - - > i { - margin-right: 4px; - } -} -</style> diff --git a/src/client/components/ui/menu.vue b/src/client/components/ui/menu.vue deleted file mode 100644 index 5b3a0ae7c2..0000000000 --- a/src/client/components/ui/menu.vue +++ /dev/null @@ -1,278 +0,0 @@ -<template> -<div class="rrevdjwt" :class="{ center: align === 'center' }" - :style="{ width: width ? width + 'px' : null }" - ref="items" - @contextmenu.self="e => e.preventDefault()" - v-hotkey="keymap" -> - <template v-for="(item, i) in _items"> - <div v-if="item === null" class="divider"></div> - <span v-else-if="item.type === 'label'" class="label item"> - <span>{{ item.text }}</span> - </span> - <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item"> - <span><MkEllipsis/></span> - </span> - <MkA v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item"> - <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> - <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </MkA> - <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item"> - <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> - <span>{{ item.text }}</span> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </a> - <button v-else-if="item.type === 'user'" @click="clicked(item.action, $event)" :tabindex="i" class="_button item"> - <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </button> - <button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> - <i v-if="item.icon" class="fa-fw" :class="item.icon"></i> - <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span> - </button> - </template> - <span v-if="_items.length === 0" class="none item"> - <span>{{ $ts.none }}</span> - </span> -</div> -</template> - -<script lang="ts"> -import { defineComponent, ref, unref } from 'vue'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import contains from '@client/scripts/contains'; - -export default defineComponent({ - props: { - items: { - type: Array, - required: true - }, - viaKeyboard: { - type: Boolean, - required: false - }, - align: { - type: String, - requried: false - }, - width: { - type: Number, - required: false - }, - }, - emits: ['close'], - data() { - return { - _items: [], - }; - }, - computed: { - keymap(): any { - return { - 'up|k|shift+tab': this.focusUp, - 'down|j|tab': this.focusDown, - 'esc': this.close, - }; - }, - }, - watch: { - items: { - handler() { - const items = ref(unref(this.items).filter(item => item !== undefined)); - - for (let i = 0; i < items.value.length; i++) { - const item = items.value[i]; - - if (item && item.then) { // if item is Promise - items.value[i] = { type: 'pending' }; - item.then(actualItem => { - items.value[i] = actualItem; - }); - } - } - - this._items = items; - }, - immediate: true - } - }, - mounted() { - if (this.viaKeyboard) { - this.$nextTick(() => { - focusNext(this.$refs.items.children[0], true, false); - }); - } - - if (this.contextmenuEvent) { - this.$el.style.top = this.contextmenuEvent.pageY + 'px'; - this.$el.style.left = this.contextmenuEvent.pageX + 'px'; - - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.addEventListener('mousedown', this.onMousedown); - } - } - }, - beforeUnmount() { - for (const el of Array.from(document.querySelectorAll('body *'))) { - el.removeEventListener('mousedown', this.onMousedown); - } - }, - methods: { - clicked(fn, ev) { - fn(ev); - this.close(); - }, - close() { - this.$emit('close'); - }, - focusUp() { - focusPrev(document.activeElement); - }, - focusDown() { - focusNext(document.activeElement); - }, - onMousedown(e) { - if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.rrevdjwt { - padding: 8px 0; - min-width: 200px; - max-height: 90vh; - overflow: auto; - - &.center { - > .item { - text-align: center; - } - } - - > .item { - display: block; - position: relative; - padding: 8px 18px; - width: 100%; - box-sizing: border-box; - white-space: nowrap; - font-size: 0.9em; - line-height: 20px; - text-align: left; - overflow: hidden; - text-overflow: ellipsis; - - &:before { - content: ""; - display: block; - position: absolute; - top: 0; - left: 0; - right: 0; - margin: auto; - width: calc(100% - 16px); - height: 100%; - border-radius: 6px; - } - - > * { - position: relative; - } - - &.danger { - color: #ff2a2a; - - &:hover { - color: #fff; - - &:before { - background: #ff4242; - } - } - - &:active { - color: #fff; - - &:before { - background: #d42e2e; - } - } - } - - &.active { - color: var(--fgOnAccent); - opacity: 1; - - &:before { - background: var(--accent); - } - } - - &:not(:disabled):hover { - color: var(--accent); - text-decoration: none; - - &:before { - background: var(--accentedBg); - } - } - - &:not(:active):focus-visible { - box-shadow: 0 0 0 2px var(--focus) inset; - } - - &.label { - pointer-events: none; - font-size: 0.7em; - padding-bottom: 4px; - - > span { - opacity: 0.7; - } - } - - &.pending { - pointer-events: none; - opacity: 0.7; - } - - &.none { - pointer-events: none; - opacity: 0.7; - } - - > i { - margin-right: 5px; - width: 20px; - } - - > .avatar { - margin-right: 5px; - width: 20px; - height: 20px; - } - - > .indicator { - position: absolute; - top: 5px; - left: 13px; - color: var(--indicator); - font-size: 12px; - animation: blink 1s infinite; - } - } - - > .divider { - margin: 8px 0; - height: 1px; - background: var(--divider); - } -} -</style> diff --git a/src/client/components/ui/modal-window.vue b/src/client/components/ui/modal-window.vue deleted file mode 100644 index da98192b87..0000000000 --- a/src/client/components/ui/modal-window.vue +++ /dev/null @@ -1,148 +0,0 @@ -<template> -<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> - <div class="ebkgoccj _window _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }"> - <div class="header"> - <button class="_button" v-if="withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button> - <span class="title"> - <slot name="header"></slot> - </span> - <button class="_button" v-if="!withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button> - <button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><i class="fas fa-check"></i></button> - </div> - <div class="body" v-if="padding"> - <div class="_section"> - <slot></slot> - </div> - </div> - <div class="body" v-else> - <slot></slot> - </div> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from './modal.vue'; - -export default defineComponent({ - components: { - MkModal - }, - props: { - withOkButton: { - type: Boolean, - required: false, - default: false - }, - okButtonDisabled: { - type: Boolean, - required: false, - default: false - }, - padding: { - type: Boolean, - required: false, - default: false - }, - width: { - type: Number, - required: false, - default: 400 - }, - height: { - type: Number, - required: false, - default: null - }, - canClose: { - type: Boolean, - required: false, - default: true, - }, - scroll: { - type: Boolean, - required: false, - default: true, - }, - }, - - emits: ['click', 'close', 'closed', 'ok'], - - data() { - return { - }; - }, - - methods: { - close() { - this.$refs.modal.close(); - }, - - onKeydown(e) { - if (e.which === 27) { // Esc - e.preventDefault(); - e.stopPropagation(); - this.close(); - } - }, - } -}); -</script> - -<style lang="scss" scoped> -.ebkgoccj { - overflow: hidden; - display: flex; - flex-direction: column; - contain: content; - - --root-margin: 24px; - - @media (max-width: 500px) { - --root-margin: 16px; - } - - > .header { - $height: 58px; - $height-narrow: 42px; - display: flex; - flex-shrink: 0; - box-shadow: 0px 1px var(--divider); - - > button { - height: $height; - width: $height; - - @media (max-width: 500px) { - height: $height-narrow; - width: $height-narrow; - } - } - - > .title { - flex: 1; - line-height: $height; - padding-left: 32px; - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - pointer-events: none; - - @media (max-width: 500px) { - line-height: $height-narrow; - padding-left: 16px; - } - } - - > button + .title { - padding-left: 0; - } - } - - > .body { - overflow: auto; - } -} -</style> diff --git a/src/client/components/ui/modal.vue b/src/client/components/ui/modal.vue deleted file mode 100644 index 33fcdb687f..0000000000 --- a/src/client/components/ui/modal.vue +++ /dev/null @@ -1,292 +0,0 @@ -<template> -<transition :name="$store.state.animation ? popup ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? popup ? 500 : 300 : 0" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> - <div v-show="manualShowing != null ? manualShowing : showing" class="qzhlnise" :class="{ front }" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> - <div class="bg _modalBg" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div> - <div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content"> - <slot></slot> - </div> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -function getFixedContainer(el: Element | null): Element | null { - if (el == null || el.tagName === 'BODY') return null; - const position = window.getComputedStyle(el).getPropertyValue('position'); - if (position === 'fixed') { - return el; - } else { - return getFixedContainer(el.parentElement); - } -} - -export default defineComponent({ - provide: { - modal: true - }, - props: { - manualShowing: { - type: Boolean, - required: false, - default: null, - }, - srcCenter: { - type: Boolean, - required: false - }, - src: { - required: false, - }, - position: { - required: false - }, - front: { - type: Boolean, - required: false, - default: false, - } - }, - emits: ['opening', 'click', 'esc', 'close', 'closed'], - data() { - return { - showing: true, - fixed: false, - transformOrigin: 'center', - contentClicking: false, - }; - }, - computed: { - keymap(): any { - return { - 'esc': () => this.$emit('esc'), - }; - }, - popup(): boolean { - return this.src != null; - } - }, - mounted() { - this.$watch('src', () => { - this.fixed = getFixedContainer(this.src) != null; - this.$nextTick(() => { - this.align(); - }); - }, { immediate: true }); - - this.$nextTick(() => { - const popover = this.$refs.content as any; - new ResizeObserver((entries, observer) => { - this.align(); - }).observe(popover); - }); - }, - methods: { - align() { - if (!this.popup) return; - - const popover = this.$refs.content as any; - - if (popover == null) return; - - const rect = this.src.getBoundingClientRect(); - - const width = popover.offsetWidth; - const height = popover.offsetHeight; - - let left; - let top; - - if (this.srcCenter) { - const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); - const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); - left = (x - (width / 2)); - top = (y - (height / 2)); - } else { - const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); - const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; - left = (x - (width / 2)); - top = y; - } - - if (this.fixed) { - if (left + width > window.innerWidth) { - left = window.innerWidth - width; - } - - if (top + height > window.innerHeight) { - top = window.innerHeight - height; - } - } else { - if (left + width - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - width + window.pageXOffset - 1; - } - - if (top + height - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - height + window.pageYOffset - 1; - } - } - - if (top < 0) { - top = 0; - } - - if (left < 0) { - left = 0; - } - - if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { - this.transformOrigin = 'center top'; - } else { - this.transformOrigin = 'center'; - } - - popover.style.left = left + 'px'; - popover.style.top = top + 'px'; - }, - - childRendered() { - // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const content = this.$refs.content.children[0]; - content.addEventListener('mousedown', e => { - this.contentClicking = true; - window.addEventListener('mouseup', e => { - // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ - setTimeout(() => { - this.contentClicking = false; - }, 100); - }, { passive: true, once: true }); - }, { passive: true }); - }, - - close() { - this.showing = false; - this.$emit('close'); - }, - - onBgClick() { - if (this.contentClicking) return; - this.$emit('click'); - }, - - onClosed() { - this.$emit('closed'); - } - } -}); -</script> - -<style lang="scss"> -.modal-popup-enter-active, .modal-popup-leave-active, -.modal-enter-from, .modal-leave-to { - > .content { - transform-origin: var(--transformOrigin); - } -} -</style> - -<style lang="scss" scoped> -.modal-enter-active, .modal-leave-active { - > .bg { - transition: opacity 0.3s !important; - } - - > .content { - transition: opacity 0.3s, transform 0.3s !important; - } -} -.modal-enter-from, .modal-leave-to { - > .bg { - opacity: 0; - } - - > .content { - pointer-events: none; - opacity: 0; - transform: scale(0.9); - } -} - -.modal-popup-enter-active, .modal-popup-leave-active { - > .bg { - transition: opacity 0.3s !important; - } - - > .content { - transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1) !important; - } -} -.modal-popup-enter-from, .modal-popup-leave-to { - > .bg { - opacity: 0; - } - - > .content { - pointer-events: none; - opacity: 0; - transform: scale(0.9); - } -} - -.qzhlnise { - > .bg { - z-index: 10000; - } - - > .content:not(.popup) { - position: fixed; - z-index: 10000; - top: 0; - bottom: 0; - left: 0; - right: 0; - margin: auto; - padding: 32px; - // TODO: mask-imageはiOSだとやたら重い。なんとかしたい - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); - overflow: auto; - display: flex; - - @media (max-width: 500px) { - padding: 16px; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); - } - - > ::v-deep(*) { - margin: auto; - } - - &.top { - > ::v-deep(*) { - margin-top: 0; - } - } - } - - > .content.popup { - position: absolute; - z-index: 10000; - - &.fixed { - position: fixed; - } - } - - &.front { - > .bg { - z-index: 20000; - } - - > .content:not(.popup) { - z-index: 20000; - } - - > .content.popup { - z-index: 20000; - } - } -} -</style> diff --git a/src/client/components/ui/pagination.vue b/src/client/components/ui/pagination.vue deleted file mode 100644 index 1bd77447b7..0000000000 --- a/src/client/components/ui/pagination.vue +++ /dev/null @@ -1,69 +0,0 @@ -<template> -<transition name="fade" mode="out-in"> - <MkLoading v-if="fetching"/> - - <MkError v-else-if="error" @retry="init()"/> - - <div class="empty" v-else-if="empty" key="_empty_"> - <slot name="empty"></slot> - </div> - - <div v-else class="cxiknjgy"> - <slot :items="items"></slot> - <div class="more _gap" v-show="more" key="_more_"> - <MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><MkLoading inline/></template> - </MkButton> - </div> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from './button.vue'; -import paging from '@client/scripts/paging'; - -export default defineComponent({ - components: { - MkButton - }, - - mixins: [ - paging({}), - ], - - props: { - pagination: { - required: true - }, - - disableAutoLoad: { - type: Boolean, - required: false, - default: false, - } - }, -}); -</script> - -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { - transition: opacity 0.125s ease; -} -.fade-enter-from, -.fade-leave-to { - opacity: 0; -} - -.cxiknjgy { - > .more > .button { - margin-left: auto; - margin-right: auto; - height: 48px; - min-width: 150px; - } -} -</style> diff --git a/src/client/components/ui/popup-menu.vue b/src/client/components/ui/popup-menu.vue deleted file mode 100644 index 3ff4c658b1..0000000000 --- a/src/client/components/ui/popup-menu.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<MkPopup ref="popup" :src="src" @closed="$emit('closed')"> - <MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/> -</MkPopup> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkPopup from './popup.vue'; -import MkMenu from './menu.vue'; - -export default defineComponent({ - components: { - MkPopup, - MkMenu, - }, - - props: { - items: { - type: Array, - required: true - }, - align: { - type: String, - required: false - }, - width: { - type: Number, - required: false - }, - viaKeyboard: { - type: Boolean, - required: false - }, - src: { - required: false - }, - }, - - emits: ['close', 'closed'], -}); -</script> diff --git a/src/client/components/ui/popup.vue b/src/client/components/ui/popup.vue deleted file mode 100644 index 0fb1780cc5..0000000000 --- a/src/client/components/ui/popup.vue +++ /dev/null @@ -1,213 +0,0 @@ -<template> -<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered"> - <div v-show="manualShowing != null ? manualShowing : showing" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" ref="content" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }"> - <slot></slot> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; - -function getFixedContainer(el: Element | null): Element | null { - if (el == null || el.tagName === 'BODY') return null; - const position = window.getComputedStyle(el).getPropertyValue('position'); - if (position === 'fixed') { - return el; - } else { - return getFixedContainer(el.parentElement); - } -} - -export default defineComponent({ - props: { - manualShowing: { - type: Boolean, - required: false, - default: null, - }, - srcCenter: { - type: Boolean, - required: false - }, - src: { - type: Object as PropType<HTMLElement>, - required: false, - }, - position: { - required: false - }, - front: { - type: Boolean, - required: false, - default: false, - } - }, - - emits: ['opening', 'click', 'esc', 'close', 'closed'], - - data() { - return { - showing: true, - fixed: false, - transformOrigin: 'center', - contentClicking: false, - }; - }, - - mounted() { - this.$watch('src', () => { - if (this.src) { - this.src.style.pointerEvents = 'none'; - } - this.fixed = getFixedContainer(this.src) != null; - this.$nextTick(() => { - this.align(); - }); - }, { immediate: true }); - - this.$nextTick(() => { - const popover = this.$refs.content as any; - new ResizeObserver((entries, observer) => { - this.align(); - }).observe(popover); - }); - - document.addEventListener('mousedown', this.onDocumentClick, { passive: true }); - }, - - beforeUnmount() { - document.removeEventListener('mousedown', this.onDocumentClick); - }, - - methods: { - align() { - if (this.src == null) return; - - const popover = this.$refs.content as any; - - if (popover == null) return; - - const rect = this.src.getBoundingClientRect(); - - const width = popover.offsetWidth; - const height = popover.offsetHeight; - - let left; - let top; - - if (this.srcCenter) { - const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); - const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2); - left = (x - (width / 2)); - top = (y - (height / 2)); - } else { - const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2); - const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight; - left = (x - (width / 2)); - top = y; - } - - if (this.fixed) { - if (left + width > window.innerWidth) { - left = window.innerWidth - width; - } - - if (top + height > window.innerHeight) { - top = window.innerHeight - height; - } - } else { - if (left + width - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - width + window.pageXOffset - 1; - } - - if (top + height - window.pageYOffset > window.innerHeight) { - top = window.innerHeight - height + window.pageYOffset - 1; - } - } - - if (top < 0) { - top = 0; - } - - if (left < 0) { - left = 0; - } - - if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) { - this.transformOrigin = 'center top'; - } else { - this.transformOrigin = 'center'; - } - - popover.style.left = left + 'px'; - popover.style.top = top + 'px'; - }, - - childRendered() { - // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const content = this.$refs.content.children[0]; - content.addEventListener('mousedown', e => { - this.contentClicking = true; - window.addEventListener('mouseup', e => { - // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ - setTimeout(() => { - this.contentClicking = false; - }, 100); - }, { passive: true, once: true }); - }, { passive: true }); - }, - - close() { - if (this.src) this.src.style.pointerEvents = 'auto'; - this.showing = false; - this.$emit('close'); - }, - - onClosed() { - this.$emit('closed'); - }, - - onDocumentClick(ev) { - const flyoutElement = this.$refs.content; - let targetElement = ev.target; - do { - if (targetElement === flyoutElement) { - return; - } - targetElement = targetElement.parentNode; - } while (targetElement); - this.close(); - } - } -}); -</script> - -<style lang="scss" scoped> -.popup-menu-enter-active { - transform-origin: var(--transformOrigin); - transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important; -} -.popup-menu-leave-active { - transform-origin: var(--transformOrigin); - transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), transform 0.2s cubic-bezier(0.4, 0, 1, 1) !important; -} -.popup-menu-enter-from, .popup-menu-leave-to { - pointer-events: none; - opacity: 0; - transform: scale(0.9); -} - -.ccczpooj { - position: absolute; - z-index: 10000; - - &.fixed { - position: fixed; - } - - &.front { - z-index: 20000; - } -} -</style> diff --git a/src/client/components/ui/super-menu.vue b/src/client/components/ui/super-menu.vue deleted file mode 100644 index 195cc57326..0000000000 --- a/src/client/components/ui/super-menu.vue +++ /dev/null @@ -1,148 +0,0 @@ -<template> -<div class="rrevdjwu" :class="{ grid }"> - <div class="group" v-for="group in def"> - <div class="title" v-if="group.title">{{ group.title }}</div> - - <div class="items"> - <template v-for="(item, i) in group.items"> - <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> - <span class="text">{{ item.text }}</span> - </a> - <button v-else-if="item.type === 'button'" @click="ev => item.action(ev)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active"> - <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> - <span class="text">{{ item.text }}</span> - </button> - <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }"> - <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i> - <span class="text">{{ item.text }}</span> - </MkA> - </template> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent, ref, unref } from 'vue'; - -export default defineComponent({ - props: { - def: { - type: Array, - required: true - }, - grid: { - type: Boolean, - required: false, - default: false, - }, - }, -}); -</script> - -<style lang="scss" scoped> -.rrevdjwu { - > .group { - & + .group { - margin-top: 16px; - padding-top: 16px; - border-top: solid 0.5px var(--divider); - } - - > .title { - font-size: 0.9em; - opacity: 0.7; - margin: 0 0 8px 12px; - } - - > .items { - > .item { - display: flex; - align-items: center; - width: 100%; - box-sizing: border-box; - padding: 10px 16px 10px 8px; - border-radius: 9px; - font-size: 0.9em; - - &:hover { - text-decoration: none; - background: var(--panelHighlight); - } - - &.active { - color: var(--accent); - background: var(--accentedBg); - } - - &.danger { - color: var(--error); - } - - > .icon { - width: 32px; - margin-right: 2px; - flex-shrink: 0; - text-align: center; - opacity: 0.8; - } - - > .text { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - padding-right: 12px; - } - - } - } - } - - &.grid { - > .group { - & + .group { - padding-top: 0; - border-top: none; - } - - margin-left: 0; - margin-right: 0; - - > .title { - font-size: 1em; - opacity: 0.7; - margin: 0 0 8px 16px; - } - - > .items { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); - grid-gap: 8px; - padding: 0 16px; - - > .item { - flex-direction: column; - padding: 18px 16px 16px 16px; - background: var(--panel); - border-radius: 8px; - text-align: center; - - > .icon { - display: block; - margin-right: 0; - margin-bottom: 12px; - font-size: 1.5em; - } - - > .text { - padding-right: 0; - width: 100%; - font-size: 0.8em; - } - } - } - } - } -} -</style> diff --git a/src/client/components/ui/tooltip.vue b/src/client/components/ui/tooltip.vue deleted file mode 100644 index c003895c14..0000000000 --- a/src/client/components/ui/tooltip.vue +++ /dev/null @@ -1,92 +0,0 @@ -<template> -<transition name="tooltip" appear @after-leave="$emit('closed')"> - <div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content" :style="{ maxWidth: maxWidth + 'px' }"> - <slot>{{ text }}</slot> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - showing: { - type: Boolean, - required: true, - }, - source: { - required: true, - }, - text: { - type: String, - required: false - }, - maxWidth: { - type: Number, - required: false, - default: 250, - }, - }, - - emits: ['closed'], - - mounted() { - this.$nextTick(() => { - if (this.source == null) { - this.$emit('closed'); - return; - } - - const rect = this.source.getBoundingClientRect(); - - const contentWidth = this.$refs.content.offsetWidth; - const contentHeight = this.$refs.content.offsetHeight; - - let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2); - let top = rect.top + window.pageYOffset - contentHeight; - - left -= (this.$el.offsetWidth / 2); - - if (left + contentWidth - window.pageXOffset > window.innerWidth) { - left = window.innerWidth - contentWidth + window.pageXOffset - 1; - } - - if (top - window.pageYOffset < 0) { - top = rect.top + window.pageYOffset + this.source.offsetHeight; - this.$refs.content.style.transformOrigin = 'center top'; - } - - this.$el.style.left = left + 'px'; - this.$el.style.top = top + 'px'; - }); - }, -}) -</script> - -<style lang="scss" scoped> -.tooltip-enter-active, -.tooltip-leave-active { - opacity: 1; - transform: scale(1); - transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tooltip-enter-from, -.tooltip-leave-active { - opacity: 0; - transform: scale(0.75); -} - -.buebdbiu { - position: absolute; - z-index: 11000; - font-size: 0.8em; - padding: 8px 12px; - box-sizing: border-box; - text-align: center; - border-radius: 4px; - border: solid 0.5px var(--divider); - pointer-events: none; - transform-origin: center bottom; -} -</style> diff --git a/src/client/components/ui/window.vue b/src/client/components/ui/window.vue deleted file mode 100644 index 00284b0467..0000000000 --- a/src/client/components/ui/window.vue +++ /dev/null @@ -1,525 +0,0 @@ -<template> -<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> - <div class="ebkgocck" :class="{ front }" v-if="showing"> - <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> - <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> - <span class="left"> - <slot name="headerLeft"></slot> - </span> - <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> - <slot name="header"></slot> - </span> - <span class="right"> - <slot name="headerRight"></slot> - <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button> - </span> - </div> - <div class="body" v-if="padding"> - <div class="_section"> - <slot></slot> - </div> - </div> - <div class="body" v-else> - <slot></slot> - </div> - </div> - <template v-if="canResize"> - <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div> - <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div> - <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div> - <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div> - <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div> - <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div> - <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div> - <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div> - </template> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import contains from '@client/scripts/contains'; -import * as os from '@client/os'; - -const minHeight = 50; -const minWidth = 250; - -function dragListen(fn) { - window.addEventListener('mousemove', fn); - window.addEventListener('touchmove', fn); - window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); - window.addEventListener('touchend', dragClear.bind(null, fn)); -} - -function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('touchmove', fn); - window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); - window.removeEventListener('touchend', dragClear); -} - -export default defineComponent({ - provide: { - inWindow: true - }, - - props: { - padding: { - type: Boolean, - required: false, - default: false - }, - initialWidth: { - type: Number, - required: false, - default: 400 - }, - initialHeight: { - type: Number, - required: false, - default: null - }, - canResize: { - type: Boolean, - required: false, - default: false, - }, - closeButton: { - type: Boolean, - required: false, - default: true, - }, - mini: { - type: Boolean, - required: false, - default: false, - }, - front: { - type: Boolean, - required: false, - default: false, - }, - contextmenu: { - type: Array, - required: false, - } - }, - - emits: ['closed'], - - data() { - return { - showing: true, - id: Math.random().toString(), // TODO: UUIDとかにする - }; - }, - - mounted() { - if (this.initialWidth) this.applyTransformWidth(this.initialWidth); - if (this.initialHeight) this.applyTransformHeight(this.initialHeight); - - this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2)); - this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2)); - - os.windows.set(this.id, { - z: Number(document.defaultView.getComputedStyle(this.$el, null).zIndex) - }); - - // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする - this.top(); - - window.addEventListener('resize', this.onBrowserResize); - }, - - unmounted() { - os.windows.delete(this.id); - window.removeEventListener('resize', this.onBrowserResize); - }, - - methods: { - close() { - this.showing = false; - }, - - onKeydown(e) { - if (e.which === 27) { // Esc - e.preventDefault(); - e.stopPropagation(); - this.close(); - } - }, - - onContextmenu(e) { - if (this.contextmenu) { - os.contextMenu(this.contextmenu, e); - } - }, - - // 最前面へ移動 - top() { - let z = 0; - const ws = Array.from(os.windows.entries()).filter(([k, v]) => k !== this.id).map(([k, v]) => v); - for (const w of ws) { - if (w.z > z) z = w.z; - } - if (z > 0) { - (this.$el as any).style.zIndex = z + 1; - os.windows.set(this.id, { - z: z + 1 - }); - } - }, - - onBodyMousedown() { - this.top(); - }, - - onHeaderMousedown(e) { - const main = this.$el as any; - - if (!contains(main, document.activeElement)) main.focus(); - - const position = main.getBoundingClientRect(); - - const clickX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX; - const clickY = e.touches && e.touches.length > 0 ? e.touches[0].clientY : e.clientY; - const moveBaseX = clickX - position.left; - const moveBaseY = clickY - position.top; - const browserWidth = window.innerWidth; - const browserHeight = window.innerHeight; - const windowWidth = main.offsetWidth; - const windowHeight = main.offsetHeight; - - // 動かした時 - dragListen(me => { - const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX; - const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY; - - let moveLeft = x - moveBaseX; - let moveTop = y - moveBaseY; - - // 下はみ出し - if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; - - // 左はみ出し - if (moveLeft < 0) moveLeft = 0; - - // 上はみ出し - if (moveTop < 0) moveTop = 0; - - // 右はみ出し - if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; - - this.$el.style.left = moveLeft + 'px'; - this.$el.style.top = moveTop + 'px'; - }); - }, - - // 上ハンドル掴み時 - onTopHandleMousedown(e) { - const main = this.$el as any; - - const base = e.clientY; - const height = parseInt(getComputedStyle(main, '').height, 10); - const top = parseInt(getComputedStyle(main, '').top, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + move > 0) { - if (height + -move > minHeight) { - this.applyTransformHeight(height + -move); - this.applyTransformTop(top + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(minHeight); - this.applyTransformTop(top + (height - minHeight)); - } - } else { // 上のはみ出し時 - this.applyTransformHeight(top + height); - this.applyTransformTop(0); - } - }); - }, - - // 右ハンドル掴み時 - onRightHandleMousedown(e) { - const main = this.$el as any; - - const base = e.clientX; - const width = parseInt(getComputedStyle(main, '').width, 10); - const left = parseInt(getComputedStyle(main, '').left, 10); - const browserWidth = window.innerWidth; - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + width + move < browserWidth) { - if (width + move > minWidth) { - this.applyTransformWidth(width + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(minWidth); - } - } else { // 右のはみ出し時 - this.applyTransformWidth(browserWidth - left); - } - }); - }, - - // 下ハンドル掴み時 - onBottomHandleMousedown(e) { - const main = this.$el as any; - - const base = e.clientY; - const height = parseInt(getComputedStyle(main, '').height, 10); - const top = parseInt(getComputedStyle(main, '').top, 10); - const browserHeight = window.innerHeight; - - // 動かした時 - dragListen(me => { - const move = me.clientY - base; - if (top + height + move < browserHeight) { - if (height + move > minHeight) { - this.applyTransformHeight(height + move); - } else { // 最小の高さより小さくなろうとした時 - this.applyTransformHeight(minHeight); - } - } else { // 下のはみ出し時 - this.applyTransformHeight(browserHeight - top); - } - }); - }, - - // 左ハンドル掴み時 - onLeftHandleMousedown(e) { - const main = this.$el as any; - - const base = e.clientX; - const width = parseInt(getComputedStyle(main, '').width, 10); - const left = parseInt(getComputedStyle(main, '').left, 10); - - // 動かした時 - dragListen(me => { - const move = me.clientX - base; - if (left + move > 0) { - if (width + -move > minWidth) { - this.applyTransformWidth(width + -move); - this.applyTransformLeft(left + move); - } else { // 最小の幅より小さくなろうとした時 - this.applyTransformWidth(minWidth); - this.applyTransformLeft(left + (width - minWidth)); - } - } else { // 左のはみ出し時 - this.applyTransformWidth(left + width); - this.applyTransformLeft(0); - } - }); - }, - - // 左上ハンドル掴み時 - onTopLeftHandleMousedown(e) { - this.onTopHandleMousedown(e); - this.onLeftHandleMousedown(e); - }, - - // 右上ハンドル掴み時 - onTopRightHandleMousedown(e) { - this.onTopHandleMousedown(e); - this.onRightHandleMousedown(e); - }, - - // 右下ハンドル掴み時 - onBottomRightHandleMousedown(e) { - this.onBottomHandleMousedown(e); - this.onRightHandleMousedown(e); - }, - - // 左下ハンドル掴み時 - onBottomLeftHandleMousedown(e) { - this.onBottomHandleMousedown(e); - this.onLeftHandleMousedown(e); - }, - - // 高さを適用 - applyTransformHeight(height) { - if (height > window.innerHeight) height = window.innerHeight; - (this.$el as any).style.height = height + 'px'; - }, - - // 幅を適用 - applyTransformWidth(width) { - if (width > window.innerWidth) width = window.innerWidth; - (this.$el as any).style.width = width + 'px'; - }, - - // Y座標を適用 - applyTransformTop(top) { - (this.$el as any).style.top = top + 'px'; - }, - - // X座標を適用 - applyTransformLeft(left) { - (this.$el as any).style.left = left + 'px'; - }, - - onBrowserResize() { - const main = this.$el as any; - const position = main.getBoundingClientRect(); - const browserWidth = window.innerWidth; - const browserHeight = window.innerHeight; - const windowWidth = main.offsetWidth; - const windowHeight = main.offsetHeight; - if (position.left < 0) main.style.left = 0; // 左はみ出し - if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し - if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し - if (position.top < 0) main.style.top = 0; // 上はみ出し - } - } -}); -</script> - -<style lang="scss" scoped> -.window-enter-active, .window-leave-active { - transition: opacity 0.2s, transform 0.2s !important; -} -.window-enter-from, .window-leave-to { - pointer-events: none; - opacity: 0; - transform: scale(0.9); -} - -.ebkgocck { - position: fixed; - top: 0; - left: 0; - z-index: 10000; // mk-modalのと同じでなければならない - - &.front { - z-index: 11000; // front指定の時は、mk-modalのよりも大きくなければならない - } - - > .body { - overflow: hidden; - display: flex; - flex-direction: column; - contain: content; - width: 100%; - height: 100%; - - > .header { - --height: 50px; - - &.mini { - --height: 38px; - } - - display: flex; - position: relative; - z-index: 1; - flex-shrink: 0; - user-select: none; - height: var(--height); - border-bottom: solid 1px var(--divider); - - > .left, > .right { - > ::v-deep(button) { - height: var(--height); - width: var(--height); - - &:hover { - color: var(--fgHighlighted); - } - } - } - - > .title { - flex: 1; - position: relative; - line-height: var(--height); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: center; - cursor: move; - } - } - - > .body { - flex: 1; - overflow: auto; - } - } - - > .handle { - $size: 8px; - - position: absolute; - - &.top { - top: -($size); - left: 0; - width: 100%; - height: $size; - cursor: ns-resize; - } - - &.right { - top: 0; - right: -($size); - width: $size; - height: 100%; - cursor: ew-resize; - } - - &.bottom { - bottom: -($size); - left: 0; - width: 100%; - height: $size; - cursor: ns-resize; - } - - &.left { - top: 0; - left: -($size); - width: $size; - height: 100%; - cursor: ew-resize; - } - - &.top-left { - top: -($size); - left: -($size); - width: $size * 2; - height: $size * 2; - cursor: nwse-resize; - } - - &.top-right { - top: -($size); - right: -($size); - width: $size * 2; - height: $size * 2; - cursor: nesw-resize; - } - - &.bottom-right { - bottom: -($size); - right: -($size); - width: $size * 2; - height: $size * 2; - cursor: nwse-resize; - } - - &.bottom-left { - bottom: -($size); - left: -($size); - width: $size * 2; - height: $size * 2; - cursor: nesw-resize; - } - } -} -</style> diff --git a/src/client/components/updated.vue b/src/client/components/updated.vue deleted file mode 100644 index 9e5b24acdb..0000000000 --- a/src/client/components/updated.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> - <div class="ewlycnyt"> - <div class="title">{{ $ts.misskeyUpdated }}</div> - <div class="version">✨{{ version }}🚀</div> - <MkButton full @click="whatIsNew">{{ $ts.whatIsNew }}</MkButton> - <MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ $ts.gotIt }}</MkButton> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from '@client/components/ui/modal.vue'; -import MkButton from '@client/components/ui/button.vue'; -import { version } from '@client/config'; - -export default defineComponent({ - components: { - MkModal, - MkButton, - }, - - data() { - return { - version: version, - }; - }, - - methods: { - whatIsNew() { - this.$refs.modal.close(); - window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank'); - } - } -}); -</script> - -<style lang="scss" scoped> -.ewlycnyt { - position: relative; - padding: 32px; - min-width: 320px; - max-width: 480px; - box-sizing: border-box; - text-align: center; - background: var(--panel); - border-radius: var(--radius); - - > .title { - font-weight: bold; - } - - > .version { - margin: 1em 0; - } - - > .gotIt { - margin: 8px 0 0 0; - } -} -</style> diff --git a/src/client/components/url-preview-popup.vue b/src/client/components/url-preview-popup.vue deleted file mode 100644 index b5e0fce207..0000000000 --- a/src/client/components/url-preview-popup.vue +++ /dev/null @@ -1,60 +0,0 @@ -<template> -<div class="fgmtyycl" :style="{ top: top + 'px', left: left + 'px' }"> - <transition name="zoom" @after-leave="$emit('closed')"> - <MkUrlPreview class="_popup _shadow" :url="url" v-if="showing"/> - </transition> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkUrlPreview from './url-preview.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkUrlPreview - }, - - props: { - url: { - type: String, - required: true - }, - source: { - required: true - }, - showing: { - type: Boolean, - required: true - }, - }, - - data() { - return { - u: null, - top: 0, - left: 0, - }; - }, - - mounted() { - const rect = this.source.getBoundingClientRect(); - const x = Math.max((rect.left + (this.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; - const y = rect.top + this.source.offsetHeight + window.pageYOffset; - - this.top = y; - this.left = x; - }, -}); -</script> - -<style lang="scss" scoped> -.fgmtyycl { - position: absolute; - z-index: 11000; - width: 500px; - max-width: calc(90vw - 12px); - pointer-events: none; -} -</style> diff --git a/src/client/components/url-preview.vue b/src/client/components/url-preview.vue deleted file mode 100644 index 1d44b04578..0000000000 --- a/src/client/components/url-preview.vue +++ /dev/null @@ -1,334 +0,0 @@ -<template> -<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> - <button class="disablePlayer" @click="playerEnabled = false" :title="$ts.disablePlayer"><i class="fas fa-times"></i></button> - <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> -</div> -<div v-else-if="tweetId && tweetExpanded" class="twitter" ref="twitter"> - <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> -</div> -<div v-else class="mk-url-preview" v-size="{ max: [400, 350] }"> - <transition name="zoom" mode="out-in"> - <component :is="self ? 'MkA' : 'a'" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching"> - <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`"> - <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$ts.enablePlayer"><i class="fas fa-play-circle"></i></button> - </div> - <article> - <header> - <h1 :title="title">{{ title }}</h1> - </header> - <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p> - <footer> - <img class="icon" v-if="icon" :src="icon"/> - <p :title="sitename">{{ sitename }}</p> - </footer> - </article> - </component> - </transition> - <div class="expandTweet" v-if="tweetId"> - <a @click="tweetExpanded = true"> - <i class="fab fa-twitter"></i> {{ $ts.expandTweet }} - </a> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { url as local, lang } from '@client/config'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - url: { - type: String, - require: true - }, - - detail: { - type: Boolean, - required: false, - default: false - }, - - compact: { - type: Boolean, - required: false, - default: false - }, - }, - - data() { - const self = this.url.startsWith(local); - return { - local, - fetching: true, - title: null, - description: null, - thumbnail: null, - icon: null, - sitename: null, - player: { - url: null, - width: null, - height: null - }, - tweetId: null, - tweetExpanded: this.detail, - embedId: `embed${Math.random().toString().replace(/\D/,'')}`, - tweetHeight: 150, - tweetLeft: 0, - playerEnabled: false, - self: self, - attr: self ? 'to' : 'href', - target: self ? null : '_blank', - }; - }, - - created() { - const requestUrl = new URL(this.url); - - if (requestUrl.hostname == 'twitter.com') { - const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/); - if (m) this.tweetId = m[1]; - } - - if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { - requestUrl.hostname = 'www.youtube.com'; - } - - const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP'); - - requestUrl.hash = ''; - - fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { - res.json().then(info => { - if (info.url == null) return; - this.title = info.title; - this.description = info.description; - this.thumbnail = info.thumbnail; - this.icon = info.icon; - this.sitename = info.sitename; - this.fetching = false; - this.player = info.player; - }) - }); - - (window as any).addEventListener('message', this.adjustTweetHeight); - }, - - mounted() { - // 300pxないと絶対右にはみ出るので左に移動してしまう - const areaWidth = (this.$el as any)?.clientWidth; - if (areaWidth && areaWidth < 300) this.tweetLeft = areaWidth - 241; - }, - - methods: { - adjustTweetHeight(message: any) { - if (message.origin !== 'https://platform.twitter.com') return; - const embed = message.data?.['twttr.embed']; - if (embed?.method !== 'twttr.private.resize') return; - if (embed?.id !== this.embedId) return; - const height = embed?.params[0]?.height; - if (height) this.tweetHeight = height; - }, - }, - - beforeUnmount() { - (window as any).removeEventListener('message', this.adjustTweetHeight); - }, -}); -</script> - -<style lang="scss" scoped> -.player { - position: relative; - width: 100%; - - > button { - position: absolute; - top: -1.5em; - right: 0; - font-size: 1em; - width: 1.5em; - height: 1.5em; - padding: 0; - margin: 0; - color: var(--fg); - background: rgba(128, 128, 128, 0.2); - opacity: 0.7; - - &:hover { - opacity: 0.9; - } - } - - > iframe { - height: 100%; - left: 0; - position: absolute; - top: 0; - width: 100%; - } -} - -.mk-url-preview { - &.max-width_400px { - > a { - font-size: 12px; - - > .thumbnail { - height: 80px; - } - - > article { - padding: 12px; - } - } - } - - &.max-width_350px { - > a { - font-size: 10px; - - > .thumbnail { - height: 70px; - } - - > article { - padding: 8px; - - > header { - margin-bottom: 4px; - } - - > footer { - margin-top: 4px; - - > img { - width: 12px; - height: 12px; - } - } - } - - &.compact { - > .thumbnail { - position: absolute; - width: 56px; - height: 100%; - } - - > article { - left: 56px; - width: calc(100% - 56px); - padding: 4px; - - > header { - margin-bottom: 2px; - } - - > footer { - margin-top: 2px; - } - } - } - } - } - - > a { - position: relative; - display: block; - font-size: 14px; - box-shadow: 0 0 0 1px var(--divider); - border-radius: 8px; - overflow: hidden; - - &:hover { - text-decoration: none; - border-color: rgba(0, 0, 0, 0.2); - - > article > header > h1 { - text-decoration: underline; - } - } - - > .thumbnail { - position: absolute; - width: 100px; - height: 100%; - background-position: center; - background-size: cover; - display: flex; - justify-content: center; - align-items: center; - - > button { - font-size: 3.5em; - opacity: 0.7; - - &:hover { - font-size: 4em; - opacity: 0.9; - } - } - - & + article { - left: 100px; - width: calc(100% - 100px); - } - } - - > article { - position: relative; - box-sizing: border-box; - padding: 16px; - - > header { - margin-bottom: 8px; - - > h1 { - margin: 0; - font-size: 1em; - } - } - - > p { - margin: 0; - font-size: 0.8em; - } - - > footer { - margin-top: 8px; - height: 16px; - - > img { - display: inline-block; - width: 16px; - height: 16px; - margin-right: 4px; - vertical-align: top; - } - - > p { - display: inline-block; - margin: 0; - color: var(--urlPreviewInfo); - font-size: 0.8em; - line-height: 16px; - vertical-align: top; - } - } - } - - &.compact { - > article { - > header h1, p, footer { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - } - } -} -</style> diff --git a/src/client/components/user-info.vue b/src/client/components/user-info.vue deleted file mode 100644 index e76f2ecaa6..0000000000 --- a/src/client/components/user-info.vue +++ /dev/null @@ -1,144 +0,0 @@ -<template> -<div class="_panel vjnjpkug"> - <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> - <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> - <div class="title"> - <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> - <p class="username"><MkAcct :user="user"/></p> - </div> - <div class="description"> - <div class="mfm" v-if="user.description"> - <Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> - </div> - <span v-else style="opacity: 0.7;">{{ $ts.noAccountDescription }}</span> - </div> - <div class="status"> - <div> - <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> - </div> - <div> - <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> - </div> - <div> - <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> - </div> - </div> - <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { parseAcct } from '@/misc/acct'; -import MkFollowButton from './follow-button.vue'; -import { userPage } from '@client/filters/user'; - -export default defineComponent({ - components: { - MkFollowButton - }, - - props: { - user: { - type: Object, - required: true - }, - }, - - data() { - return { - }; - }, - - methods: { - userPage, - parseAcct, - } -}); -</script> - -<style lang="scss" scoped> -.vjnjpkug { - position: relative; - - > .banner { - height: 84px; - background-color: rgba(0, 0, 0, 0.1); - background-size: cover; - background-position: center; - } - - > .avatar { - display: block; - position: absolute; - top: 62px; - left: 13px; - z-index: 2; - width: 58px; - height: 58px; - border: solid 4px var(--panel); - } - - > .title { - display: block; - padding: 10px 0 10px 88px; - - > .name { - display: inline-block; - margin: 0; - font-weight: bold; - line-height: 16px; - word-break: break-all; - } - - > .username { - display: block; - margin: 0; - line-height: 16px; - font-size: 0.8em; - color: var(--fg); - opacity: 0.7; - } - } - - > .description { - padding: 16px; - font-size: 0.8em; - border-top: solid 0.5px var(--divider); - - > .mfm { - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - } - } - - > .status { - padding: 10px 16px; - border-top: solid 0.5px var(--divider); - - > div { - display: inline-block; - width: 33%; - - > p { - margin: 0; - font-size: 0.7em; - color: var(--fg); - } - - > span { - font-size: 1em; - color: var(--accent); - } - } - } - - > .koudoku-button { - position: absolute; - top: 8px; - right: 8px; - } -} -</style> diff --git a/src/client/components/user-list.vue b/src/client/components/user-list.vue deleted file mode 100644 index 9c91183971..0000000000 --- a/src/client/components/user-list.vue +++ /dev/null @@ -1,91 +0,0 @@ -<template> -<MkError v-if="error" @retry="init()"/> - -<div v-else class="efvhhmdq _isolated"> - <div class="no-users" v-if="empty"> - <p>{{ $ts.noUsers }}</p> - </div> - <div class="users"> - <MkUserInfo class="user" v-for="user in users" :user="user" :key="user.id"/> - </div> - <button class="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :class="{ fetching: moreFetching }" v-show="more" :disabled="moreFetching"> - <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }} - </button> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import paging from '@client/scripts/paging'; -import MkUserInfo from './user-info.vue'; -import { userPage } from '@client/filters/user'; - -export default defineComponent({ - components: { - MkUserInfo, - }, - - mixins: [ - paging({}), - ], - - props: { - pagination: { - required: true - }, - extract: { - required: false - }, - expanded: { - type: Boolean, - default: true - }, - }, - - computed: { - users() { - return this.extract ? this.extract(this.items) : this.items; - } - }, - - methods: { - userPage - } -}); -</script> - -<style lang="scss" scoped> -.efvhhmdq { - > .no-users { - text-align: center; - } - - > .users { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - grid-gap: var(--margin); - } - - > .more { - display: block; - width: 100%; - padding: 16px; - - &:hover { - background: rgba(#000, 0.025); - } - - &:active { - background: rgba(#000, 0.05); - } - - &.fetching { - cursor: wait; - } - - > i { - margin-right: 4px; - } - } -} -</style> diff --git a/src/client/components/user-online-indicator.vue b/src/client/components/user-online-indicator.vue deleted file mode 100644 index afaf0e8736..0000000000 --- a/src/client/components/user-online-indicator.vue +++ /dev/null @@ -1,50 +0,0 @@ -<template> -<div class="fzgwjkgc" :class="user.onlineStatus" v-tooltip="text"></div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ - props: { - user: { - type: Object, - required: true - }, - }, - - computed: { - text(): string { - switch (this.user.onlineStatus) { - case 'online': return this.$ts.online; - case 'active': return this.$ts.active; - case 'offline': return this.$ts.offline; - case 'unknown': return this.$ts.unknown; - } - } - } -}); -</script> - -<style lang="scss" scoped> -.fzgwjkgc { - box-shadow: 0 0 0 3px var(--panel); - border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる - - &.online { - background: #58d4c9; - } - - &.active { - background: #e4bc48; - } - - &.offline { - background: #ea5353; - } - - &.unknown { - background: #888; - } -} -</style> diff --git a/src/client/components/user-preview.vue b/src/client/components/user-preview.vue deleted file mode 100644 index 1249f205aa..0000000000 --- a/src/client/components/user-preview.vue +++ /dev/null @@ -1,192 +0,0 @@ -<template> -<transition name="popup" appear @after-leave="$emit('closed')"> - <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }"> - <div v-if="fetched" class="info"> - <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> - <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> - <div class="title"> - <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> - <p class="username"><MkAcct :user="user"/></p> - </div> - <div class="description"> - <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/> - </div> - <div class="status"> - <div> - <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span> - </div> - <div> - <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span> - </div> - <div> - <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> - </div> - </div> - <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/> - </div> - <div v-else> - <MkLoading/> - </div> - </div> -</transition> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { parseAcct } from '@/misc/acct'; -import MkFollowButton from './follow-button.vue'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkFollowButton - }, - - props: { - showing: { - type: Boolean, - required: true - }, - q: { - type: String, - required: true - }, - source: { - required: true - } - }, - - emits: ['closed', 'mouseover', 'mouseleave'], - - data() { - return { - user: null, - fetched: false, - top: 0, - left: 0, - }; - }, - - mounted() { - if (typeof this.q == 'object') { - this.user = this.q; - this.fetched = true; - } else { - const query = this.q.startsWith('@') ? - parseAcct(this.q.substr(1)) : - { userId: this.q }; - - os.api('users/show', query).then(user => { - if (!this.showing) return; - this.user = user; - this.fetched = true; - }); - } - - const rect = this.source.getBoundingClientRect(); - const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; - const y = rect.top + this.source.offsetHeight + window.pageYOffset; - - this.top = y; - this.left = x; - }, - - methods: { - userPage - } -}); -</script> - -<style lang="scss" scoped> -.popup-enter-active, .popup-leave-active { - transition: opacity 0.3s, transform 0.3s !important; -} -.popup-enter-from, .popup-leave-to { - opacity: 0; - transform: scale(0.9); -} - -.fxxzrfni { - position: absolute; - z-index: 11000; - width: 300px; - overflow: hidden; - transform-origin: center top; - - > .info { - > .banner { - height: 84px; - background-color: rgba(0, 0, 0, 0.1); - background-size: cover; - background-position: center; - } - - > .avatar { - display: block; - position: absolute; - top: 62px; - left: 13px; - z-index: 2; - width: 58px; - height: 58px; - border: solid 3px var(--face); - border-radius: 8px; - } - - > .title { - display: block; - padding: 8px 0 8px 82px; - - > .name { - display: inline-block; - margin: 0; - font-weight: bold; - line-height: 16px; - word-break: break-all; - } - - > .username { - display: block; - margin: 0; - line-height: 16px; - font-size: 0.8em; - color: var(--fg); - opacity: 0.7; - } - } - - > .description { - padding: 0 16px; - font-size: 0.8em; - color: var(--fg); - } - - > .status { - padding: 8px 16px; - - > div { - display: inline-block; - width: 33%; - - > p { - margin: 0; - font-size: 0.7em; - color: var(--fg); - } - - > span { - font-size: 1em; - color: var(--accent); - } - } - } - - > .koudoku-button { - position: absolute; - top: 8px; - right: 8px; - } - } -} -</style> diff --git a/src/client/components/user-select-dialog.vue b/src/client/components/user-select-dialog.vue deleted file mode 100644 index 0f3ee2a126..0000000000 --- a/src/client/components/user-select-dialog.vue +++ /dev/null @@ -1,199 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :with-ok-button="true" - :ok-button-disabled="selected == null" - @click="cancel()" - @close="cancel()" - @ok="ok()" - @closed="$emit('closed')" -> - <template #header>{{ $ts.selectUser }}</template> - <div class="tbhwbxda _monolithic_"> - <div class="_section"> - <div class="_inputSplit"> - <MkInput v-model="username" class="input" @update:modelValue="search" ref="username"> - <template #label>{{ $ts.username }}</template> - <template #prefix>@</template> - </MkInput> - <MkInput v-model="host" class="input" @update:modelValue="search"> - <template #label>{{ $ts.host }}</template> - <template #prefix>@</template> - </MkInput> - </div> - </div> - <div class="_section result" v-if="username != '' || host != ''" :class="{ hit: users.length > 0 }"> - <div class="users" v-if="users.length > 0"> - <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> - <MkAvatar :user="user" class="avatar" :show-indicator="true"/> - <div class="body"> - <MkUserName :user="user" class="name"/> - <MkAcct :user="user" class="acct"/> - </div> - </div> - </div> - <div v-else class="empty"> - <span>{{ $ts.noUsers }}</span> - </div> - </div> - <div class="_section recent" v-if="username == '' && host == ''"> - <div class="users"> - <div class="user" v-for="user in recentUsers" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()"> - <MkAvatar :user="user" class="avatar" :show-indicator="true"/> - <div class="body"> - <MkUserName :user="user" class="name"/> - <MkAcct :user="user" class="acct"/> - </div> - </div> - </div> - </div> - </div> -</XModalWindow> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkInput from './form/input.vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - MkInput, - XModalWindow, - }, - - props: { - }, - - emits: ['ok', 'cancel', 'closed'], - - data() { - return { - username: '', - host: '', - recentUsers: [], - users: [], - selected: null, - }; - }, - - async mounted() { - this.focus(); - - this.$nextTick(() => { - this.focus(); - }); - - this.recentUsers = await os.api('users/show', { - userIds: this.$store.state.recentlyUsedUsers - }); - }, - - methods: { - search() { - if (this.username == '' && this.host == '') { - this.users = []; - return; - } - os.api('users/search-by-username-and-host', { - username: this.username, - host: this.host, - limit: 10, - detail: false - }).then(users => { - this.users = users; - }); - }, - - focus() { - this.$refs.username.focus(); - }, - - ok() { - this.$emit('ok', this.selected); - this.$refs.dialog.close(); - - // 最近使ったユーザー更新 - let recents = this.$store.state.recentlyUsedUsers; - recents = recents.filter(x => x !== this.selected.id); - recents.unshift(this.selected.id); - this.$store.set('recentlyUsedUsers', recents.splice(0, 16)); - }, - - cancel() { - this.$emit('cancel'); - this.$refs.dialog.close(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.tbhwbxda { - > ._section { - display: flex; - flex-direction: column; - overflow: auto; - height: 100%; - - &.result.hit { - padding: 0; - } - - &.recent { - padding: 0; - } - - > .users { - flex: 1; - overflow: auto; - padding: 8px 0; - - > .user { - display: flex; - align-items: center; - padding: 8px var(--root-margin); - font-size: 14px; - - &:hover { - background: var(--X7); - } - - &.selected { - background: var(--accent); - color: #fff; - } - - > * { - pointer-events: none; - user-select: none; - } - - > .avatar { - width: 45px; - height: 45px; - } - - > .body { - padding: 0 8px; - min-width: 0; - - > .name { - display: block; - font-weight: bold; - } - - > .acct { - opacity: 0.5; - } - } - } - } - - > .empty { - opacity: 0.7; - text-align: center; - } - } -} -</style> diff --git a/src/client/components/users-dialog.vue b/src/client/components/users-dialog.vue deleted file mode 100644 index 5199f34c14..0000000000 --- a/src/client/components/users-dialog.vue +++ /dev/null @@ -1,147 +0,0 @@ -<template> -<div class="mk-users-dialog"> - <div class="header"> - <span>{{ title }}</span> - <button class="_button" @click="close()"><i class="fas fa-times"></i></button> - </div> - - <div class="users"> - <MkA v-for="item in items" class="user" :key="item.id" :to="userPage(extract ? extract(item) : item)"> - <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true" :show-indicator="true"/> - <div class="body"> - <MkUserName :user="extract ? extract(item) : item" class="name"/> - <MkAcct :user="extract ? extract(item) : item" class="acct"/> - </div> - </MkA> - </div> - <button class="more _button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching"> - <template v-if="!moreFetching">{{ $ts.loadMore }}</template> - <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> - </button> - - <p class="empty" v-if="empty">{{ $ts.noUsers }}</p> - - <MkError v-if="error" @retry="init()"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import paging from '@client/scripts/paging'; -import { userPage } from '@client/filters/user'; - -export default defineComponent({ - mixins: [ - paging({}), - ], - - props: { - title: { - required: true - }, - pagination: { - required: true - }, - extract: { - required: false - } - }, - - data() { - return { - }; - }, - - methods: { - userPage - } -}); -</script> - -<style lang="scss" scoped> -.mk-users-dialog { - width: 350px; - height: 350px; - background: var(--panel); - border-radius: var(--radius); - overflow: hidden; - display: flex; - flex-direction: column; - - > .header { - display: flex; - flex-shrink: 0; - - > button { - height: 58px; - width: 58px; - - @media (max-width: 500px) { - height: 42px; - width: 42px; - } - } - - > span { - flex: 1; - line-height: 58px; - padding-left: 32px; - font-weight: bold; - - @media (max-width: 500px) { - line-height: 42px; - padding-left: 16px; - } - } - } - - > .users { - flex: 1; - overflow: auto; - - &:empty { - display: none; - } - - > .user { - display: flex; - align-items: center; - font-size: 14px; - padding: 8px 32px; - - @media (max-width: 500px) { - padding: 8px 16px; - } - - > * { - pointer-events: none; - user-select: none; - } - - > .avatar { - width: 45px; - height: 45px; - } - - > .body { - padding: 0 8px; - overflow: hidden; - - > .name { - display: block; - font-weight: bold; - } - - > .acct { - opacity: 0.5; - } - } - } - } - - > .empty { - text-align: center; - opacity: 0.5; - } -} -</style> diff --git a/src/client/components/visibility-picker.vue b/src/client/components/visibility-picker.vue deleted file mode 100644 index 492ec092e3..0000000000 --- a/src/client/components/visibility-picker.vue +++ /dev/null @@ -1,167 +0,0 @@ -<template> -<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> - <div class="gqyayizv _popup"> - <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="1" key="public"> - <div><i class="fas fa-globe"></i></div> - <div> - <span>{{ $ts._visibility.public }}</span> - <span>{{ $ts._visibility.publicDescription }}</span> - </div> - </button> - <button class="_button" @click="choose('home')" :class="{ active: v == 'home' }" data-index="2" key="home"> - <div><i class="fas fa-home"></i></div> - <div> - <span>{{ $ts._visibility.home }}</span> - <span>{{ $ts._visibility.homeDescription }}</span> - </div> - </button> - <button class="_button" @click="choose('followers')" :class="{ active: v == 'followers' }" data-index="3" key="followers"> - <div><i class="fas fa-unlock"></i></div> - <div> - <span>{{ $ts._visibility.followers }}</span> - <span>{{ $ts._visibility.followersDescription }}</span> - </div> - </button> - <button :disabled="localOnly" class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="4" key="specified"> - <div><i class="fas fa-envelope"></i></div> - <div> - <span>{{ $ts._visibility.specified }}</span> - <span>{{ $ts._visibility.specifiedDescription }}</span> - </div> - </button> - <div class="divider"></div> - <button class="_button localOnly" @click="localOnly = !localOnly" :class="{ active: localOnly }" data-index="5" key="localOnly"> - <div><i class="fas fa-biohazard"></i></div> - <div> - <span>{{ $ts._visibility.localOnly }}</span> - <span>{{ $ts._visibility.localOnlyDescription }}</span> - </div> - <div><i :class="localOnly ? 'fas fa-toggle-on' : 'fas fa-toggle-off'"></i></div> - </button> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from '@client/components/ui/modal.vue'; - -export default defineComponent({ - components: { - MkModal, - }, - props: { - currentVisibility: { - type: String, - required: true - }, - currentLocalOnly: { - type: Boolean, - required: true - }, - src: { - required: false - }, - }, - emits: ['change-visibility', 'change-local-only', 'closed'], - data() { - return { - v: this.currentVisibility, - localOnly: this.currentLocalOnly, - } - }, - watch: { - localOnly() { - this.$emit('change-local-only', this.localOnly); - } - }, - methods: { - choose(visibility) { - this.v = visibility; - this.$emit('change-visibility', visibility); - this.$nextTick(() => { - this.$refs.modal.close(); - }); - }, - } -}); -</script> - -<style lang="scss" scoped> -.gqyayizv { - width: 240px; - padding: 8px 0; - - > .divider { - margin: 8px 0; - border-top: solid 0.5px var(--divider); - } - - > button { - display: flex; - padding: 8px 14px; - font-size: 12px; - text-align: left; - width: 100%; - box-sizing: border-box; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &:active { - background: rgba(0, 0, 0, 0.1); - } - - &.active { - color: #fff; - background: var(--accent); - } - - &.localOnly.active { - color: var(--accent); - background: inherit; - } - - > *:nth-child(1) { - display: flex; - justify-content: center; - align-items: center; - margin-right: 10px; - width: 16px; - top: 0; - bottom: 0; - margin-top: auto; - margin-bottom: auto; - } - - > *:nth-child(2) { - flex: 1 1 auto; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - > span:first-child { - display: block; - font-weight: bold; - } - - > span:last-child:not(:first-child) { - opacity: 0.6; - } - } - - > *:nth-child(3) { - display: flex; - justify-content: center; - align-items: center; - margin-left: 10px; - width: 16px; - top: 0; - bottom: 0; - margin-top: auto; - margin-bottom: auto; - } - } -} -</style> diff --git a/src/client/components/waiting-dialog.vue b/src/client/components/waiting-dialog.vue deleted file mode 100644 index ea9f6756b2..0000000000 --- a/src/client/components/waiting-dialog.vue +++ /dev/null @@ -1,92 +0,0 @@ -<template> -<MkModal ref="modal" @click="success ? done() : () => {}" @closed="$emit('closed')"> - <div class="iuyakobc" :class="{ iconOnly: (text == null) || success }"> - <i v-if="success" class="fas fa-check icon success"></i> - <i v-else class="fas fa-spinner fa-pulse icon waiting"></i> - <div class="text" v-if="text && !success">{{ text }}<MkEllipsis/></div> - </div> -</MkModal> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkModal from '@client/components/ui/modal.vue'; - -export default defineComponent({ - components: { - MkModal, - }, - - props: { - success: { - type: Boolean, - required: true, - }, - showing: { - type: Boolean, - required: true, - }, - text: { - type: String, - required: false, - }, - }, - - emits: ['done', 'closed'], - - data() { - return { - }; - }, - - watch: { - showing() { - if (!this.showing) this.done(); - } - }, - - methods: { - done() { - this.$emit('done'); - this.$refs.modal.close(); - }, - } -}); -</script> - -<style lang="scss" scoped> -.iuyakobc { - position: relative; - padding: 32px; - box-sizing: border-box; - text-align: center; - background: var(--panel); - border-radius: var(--radius); - width: 250px; - - &.iconOnly { - padding: 0; - width: 96px; - height: 96px; - display: flex; - align-items: center; - justify-content: center; - } - - > .icon { - font-size: 32px; - - &.success { - color: var(--accent); - } - - &.waiting { - opacity: 0.7; - } - } - - > .text { - margin-top: 16px; - } -} -</style> diff --git a/src/client/components/widgets.vue b/src/client/components/widgets.vue deleted file mode 100644 index aef5de453c..0000000000 --- a/src/client/components/widgets.vue +++ /dev/null @@ -1,152 +0,0 @@ -<template> -<div class="vjoppmmu"> - <template v-if="edit"> - <header> - <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)"> - <template #label>{{ $ts.selectWidget }}</template> - <option v-for="widget in widgetDefs" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option> - </MkSelect> - <MkButton inline @click="addWidget" primary><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> - <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton> - </header> - <XDraggable - v-model="_widgets" - item-key="id" - animation="150" - > - <template #item="{element}"> - <div class="customize-container"> - <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button> - <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button> - <component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" @updateProps="updateWidget(element.id, $event)"/> - </div> - </template> - </XDraggable> - </template> - <component v-else class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" @updateProps="updateWidget(widget.id, $event)"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { v4 as uuid } from 'uuid'; -import MkSelect from '@client/components/form/select.vue'; -import MkButton from '@client/components/ui/button.vue'; -import { widgets as widgetDefs } from '@client/widgets'; - -export default defineComponent({ - components: { - XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - MkSelect, - MkButton, - }, - - props: { - widgets: { - type: Array, - required: true, - }, - edit: { - type: Boolean, - required: true, - }, - }, - - emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'], - - data() { - return { - widgetAdderSelected: null, - widgetDefs, - settings: {}, - }; - }, - - computed: { - _widgets: { - get() { - return this.widgets; - }, - set(value) { - this.$emit('updateWidgets', value); - } - } - }, - - methods: { - configWidget(id) { - this.settings[id](); - }, - - addWidget() { - if (this.widgetAdderSelected == null) return; - - this.$emit('addWidget', { - name: this.widgetAdderSelected, - id: uuid(), - data: {} - }); - - this.widgetAdderSelected = null; - }, - - removeWidget(widget) { - this.$emit('removeWidget', widget); - }, - - updateWidget(id, data) { - this.$emit('updateWidget', { id, data }); - }, - } -}); -</script> - -<style lang="scss" scoped> -.vjoppmmu { - > header { - margin: 16px 0; - - > * { - width: 100%; - padding: 4px; - } - } - - > .widget, .customize-container { - margin: var(--margin) 0; - - &:first-of-type { - margin-top: 0; - } - } - - .customize-container { - position: relative; - cursor: move; - - > *:not(.remove):not(.config) { - pointer-events: none; - } - - > .config, - > .remove { - position: absolute; - z-index: 10000; - top: 8px; - width: 32px; - height: 32px; - color: #fff; - background: rgba(#000, 0.7); - border-radius: 4px; - } - - > .config { - right: 8px + 8px + 32px; - } - - > .remove { - right: 8px; - } - } -} -</style> diff --git a/src/client/config.ts b/src/client/config.ts deleted file mode 100644 index f2022b0f02..0000000000 --- a/src/client/config.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/src/client/directives/anim.ts b/src/client/directives/anim.ts deleted file mode 100644 index 1ceef984d8..0000000000 --- a/src/client/directives/anim.ts +++ /dev/null @@ -1,18 +0,0 @@ -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/src/client/directives/appear.ts b/src/client/directives/appear.ts deleted file mode 100644 index a504d11ef9..0000000000 --- a/src/client/directives/appear.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/src/client/directives/click-anime.ts b/src/client/directives/click-anime.ts deleted file mode 100644 index 0d5a6da94e..0000000000 --- a/src/client/directives/click-anime.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Directive } from 'vue'; -import { defaultStore } from '@client/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/src/client/directives/follow-append.ts b/src/client/directives/follow-append.ts deleted file mode 100644 index ef3f9af43f..0000000000 --- a/src/client/directives/follow-append.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Directive } from 'vue'; -import { getScrollContainer, getScrollPosition } from '@client/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/src/client/directives/get-size.ts b/src/client/directives/get-size.ts deleted file mode 100644 index e3b5dea0f3..0000000000 --- a/src/client/directives/get-size.ts +++ /dev/null @@ -1,34 +0,0 @@ -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/src/client/directives/hotkey.ts b/src/client/directives/hotkey.ts deleted file mode 100644 index d813a95074..0000000000 --- a/src/client/directives/hotkey.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/src/client/directives/index.ts b/src/client/directives/index.ts deleted file mode 100644 index cd71bc26d3..0000000000 --- a/src/client/directives/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -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/src/client/directives/particle.ts b/src/client/directives/particle.ts deleted file mode 100644 index 1676e1182e..0000000000 --- a/src/client/directives/particle.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Particle from '@client/components/particle.vue'; -import { popup } from '@client/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/src/client/directives/size.ts b/src/client/directives/size.ts deleted file mode 100644 index a72a97abcc..0000000000 --- a/src/client/directives/size.ts +++ /dev/null @@ -1,68 +0,0 @@ -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/src/client/directives/sticky-container.ts b/src/client/directives/sticky-container.ts deleted file mode 100644 index 9610eba4da..0000000000 --- a/src/client/directives/sticky-container.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/src/client/directives/tooltip.ts b/src/client/directives/tooltip.ts deleted file mode 100644 index 32d137b2e2..0000000000 --- a/src/client/directives/tooltip.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Directive, ref } from 'vue'; -import { isDeviceTouch } from '@client/scripts/is-device-touch'; -import { popup, dialog } from '@client/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('@client/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/src/client/directives/user-preview.ts b/src/client/directives/user-preview.ts deleted file mode 100644 index 26d6c5efb3..0000000000 --- a/src/client/directives/user-preview.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Directive, ref } from 'vue'; -import autobind from 'autobind-decorator'; -import { popup } from '@client/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('@client/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/src/client/events.ts b/src/client/events.ts deleted file mode 100644 index dbbd908b8f..0000000000 --- a/src/client/events.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { EventEmitter } from 'eventemitter3'; - -// TODO: 型付け -export const globalEvents = new EventEmitter(); diff --git a/src/client/filters/bytes.ts b/src/client/filters/bytes.ts deleted file mode 100644 index 50e63534b6..0000000000 --- a/src/client/filters/bytes.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/src/client/filters/note.ts b/src/client/filters/note.ts deleted file mode 100644 index 5c000cf83b..0000000000 --- a/src/client/filters/note.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default note => { - return `/notes/${note.id}`; -}; diff --git a/src/client/filters/number.ts b/src/client/filters/number.ts deleted file mode 100644 index 880a848ca4..0000000000 --- a/src/client/filters/number.ts +++ /dev/null @@ -1 +0,0 @@ -export default n => n == null ? 'N/A' : n.toLocaleString(); diff --git a/src/client/filters/user.ts b/src/client/filters/user.ts deleted file mode 100644 index f432de739d..0000000000 --- a/src/client/filters/user.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getAcct } from '@/misc/acct'; -import getUserName from '@/misc/get-user-name'; -import { url } from '@client/config'; - -export const acct = user => { - return getAcct(user); -}; - -export const userName = user => { - return getUserName(user); -}; - -export const userPage = (user, path?, absolute = false) => { - return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; -}; diff --git a/src/client/i18n.ts b/src/client/i18n.ts deleted file mode 100644 index dc23676474..0000000000 --- a/src/client/i18n.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { markRaw } from 'vue'; -import { locale } from '@client/config'; -import { I18n } from '@/misc/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/src/client/init.ts b/src/client/init.ts deleted file mode 100644 index 81d17f0d27..0000000000 --- a/src/client/init.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * Client entry point - */ - -import '@client/style.scss'; - -//#region account indexedDB migration -import { set } from '@client/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 '@client/widgets'; -import directives from '@client/directives'; -import components from '@client/components'; -import { version, ui, lang, host } from '@client/config'; -import { router } from '@client/router'; -import { applyTheme } from '@client/scripts/theme'; -import { isDeviceDarkmode } from '@client/scripts/is-device-darkmode'; -import { i18n } from '@client/i18n'; -import { stream, dialog, post, popup } from '@client/os'; -import * as sound from '@client/scripts/sound'; -import { $i, refreshAccount, login, updateAccount, signout } from '@client/account'; -import { defaultStore, ColdDeviceStorage } from '@client/store'; -import { fetchInstance, instance } from '@client/instance'; -import { makeHotkey } from '@client/scripts/hotkey'; -import { search } from '@client/scripts/search'; -import { isMobile } from '@client/scripts/is-mobile'; -import { initializeSw } from '@client/scripts/initialize-sw'; -import { reloadChannel } from '@client/scripts/unison-reload'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { getUrlWithoutLoginId } from '@client/scripts/login-id'; -import { getAccountFromId } from '@client/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('@client/ui/zen.vue') : - !$i ? import('@client/ui/visitor.vue') : - ui === 'deck' ? import('@client/ui/deck.vue') : - ui === 'desktop' ? import('@client/ui/desktop.vue') : - ui === 'chat' ? import('@client/ui/chat/index.vue') : - ui === 'classic' ? import('@client/ui/classic.vue') : - import('@client/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('@client/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/src/client/instance.ts b/src/client/instance.ts deleted file mode 100644 index 6e912aa2e5..0000000000 --- a/src/client/instance.ts +++ /dev/null @@ -1,52 +0,0 @@ -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/src/client/menu.ts b/src/client/menu.ts deleted file mode 100644 index c884772a47..0000000000 --- a/src/client/menu.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { computed, ref } from 'vue'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import { i18n } from '@client/i18n'; -import { ui } from '@client/config'; -import { $i } from './account'; -import { unisonReload } from '@client/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/src/client/os.ts b/src/client/os.ts deleted file mode 100644 index 743d2d131f..0000000000 --- a/src/client/os.ts +++ /dev/null @@ -1,501 +0,0 @@ -// 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 '@client/config'; -import MkPostFormDialog from '@client/components/post-form-dialog.vue'; -import MkWaitingDialog from '@client/components/waiting-dialog.vue'; -import { resolve } from '@client/router'; -import { $i } from '@client/account'; -import { defaultStore } from '@client/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('@client/components/page-window.vue'), { - initialPath: path, - initialComponent: markRaw(component), - initialProps: props, - }, {}, 'closed'); -} - -export function modalPageWindow(path: string) { - const { component, props } = resolve(path); - popup(import('@client/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('@client/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('@client/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('@client/components/waiting-dialog.vue'), { - success: false, - showing: showing - }, { - done: () => resolve(), - }, 'closed'); - }); -} - -export function form(title, form) { - return new Promise((resolve, reject) => { - popup(import('@client/components/form-dialog.vue'), { title, form }, { - done: result => { - resolve(result); - }, - }, 'closed'); - }); -} - -export async function selectUser() { - return new Promise((resolve, reject) => { - popup(import('@client/components/user-select-dialog.vue'), {}, { - ok: user => { - resolve(user); - }, - }, 'closed'); - }); -} - -export async function selectDriveFile(multiple: boolean) { - return new Promise((resolve, reject) => { - popup(import('@client/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('@client/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('@client/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('@client/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('@client/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('@client/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/src/client/pages/_error_.vue b/src/client/pages/_error_.vue deleted file mode 100644 index d1cefad8dd..0000000000 --- a/src/client/pages/_error_.vue +++ /dev/null @@ -1,94 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import * as symbols from '@client/symbols'; -import { version } from '@client/config'; -import * as os from '@client/os'; -import { unisonReload } from '@client/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/src/client/pages/_loading_.vue b/src/client/pages/_loading_.vue deleted file mode 100644 index 34ecaf9b33..0000000000 --- a/src/client/pages/_loading_.vue +++ /dev/null @@ -1,10 +0,0 @@ -<template> -<MkLoading/> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; - -export default defineComponent({}); -</script> diff --git a/src/client/pages/about-misskey.vue b/src/client/pages/about-misskey.vue deleted file mode 100644 index decee3a0f0..0000000000 --- a/src/client/pages/about-misskey.vue +++ /dev/null @@ -1,238 +0,0 @@ -<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="/static-assets/client/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 '@client/config'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import MkLink from '@client/components/link.vue'; -import { physics } from '@client/scripts/physics'; -import * as symbols from '@client/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/src/client/pages/about.vue b/src/client/pages/about.vue deleted file mode 100644 index 2c580c293a..0000000000 --- a/src/client/pages/about.vue +++ /dev/null @@ -1,123 +0,0 @@ -<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 '@client/config'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import number from '@client/filters/number'; -import * as symbols from '@client/symbols'; -import { host } from '@client/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/src/client/pages/admin/abuses.vue b/src/client/pages/admin/abuses.vue deleted file mode 100644 index fb99347f34..0000000000 --- a/src/client/pages/admin/abuses.vue +++ /dev/null @@ -1,170 +0,0 @@ -<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 { parseAcct } from '@/misc/acct'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import { acct } from '@client/filters/user'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/ads.vue b/src/client/pages/admin/ads.vue deleted file mode 100644 index 4d39bb4e40..0000000000 --- a/src/client/pages/admin/ads.vue +++ /dev/null @@ -1,138 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkRadio from '@client/components/form/radio.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/announcements.vue b/src/client/pages/admin/announcements.vue deleted file mode 100644 index 4ace515b0b..0000000000 --- a/src/client/pages/admin/announcements.vue +++ /dev/null @@ -1,125 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/bot-protection.vue b/src/client/pages/admin/bot-protection.vue deleted file mode 100644 index 731f114cc2..0000000000 --- a/src/client/pages/admin/bot-protection.vue +++ /dev/null @@ -1,138 +0,0 @@ -<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 '@client/components/debobigego/radios.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/instance'; - -export default defineComponent({ - components: { - FormRadios, - FormInput, - FormBase, - FormGroup, - FormButton, - FormInfo, - FormSuspense, - MkCaptcha: defineAsyncComponent(() => import('@client/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/src/client/pages/admin/database.vue b/src/client/pages/admin/database.vue deleted file mode 100644 index ffbeed8b30..0000000000 --- a/src/client/pages/admin/database.vue +++ /dev/null @@ -1,61 +0,0 @@ -<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 '@client/components/debobigego/suspense.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import bytes from '@client/filters/bytes'; -import number from '@client/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/src/client/pages/admin/email-settings.vue b/src/client/pages/admin/email-settings.vue deleted file mode 100644 index ebf724fcdd..0000000000 --- a/src/client/pages/admin/email-settings.vue +++ /dev/null @@ -1,128 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/emoji-edit-dialog.vue b/src/client/pages/admin/emoji-edit-dialog.vue deleted file mode 100644 index 4854c69884..0000000000 --- a/src/client/pages/admin/emoji-edit-dialog.vue +++ /dev/null @@ -1,120 +0,0 @@ -<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 '@client/components/ui/modal-window.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import * as os from '@client/os'; -import { unique } from '../../../prelude/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/src/client/pages/admin/emojis.vue b/src/client/pages/admin/emojis.vue deleted file mode 100644 index 5876db349e..0000000000 --- a/src/client/pages/admin/emojis.vue +++ /dev/null @@ -1,263 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkTab from '@client/components/tab.vue'; -import { selectFile } from '@client/scripts/select-file'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/file-dialog.vue b/src/client/pages/admin/file-dialog.vue deleted file mode 100644 index 02d83e5022..0000000000 --- a/src/client/pages/admin/file-dialog.vue +++ /dev/null @@ -1,129 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import XModalWindow from '@client/components/ui/modal-window.vue'; -import MkDriveFileThumbnail from '@client/components/drive-file-thumbnail.vue'; -import Progress from '@client/scripts/loading'; -import bytes from '@client/filters/bytes'; -import * as os from '@client/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/src/client/pages/admin/files-settings.vue b/src/client/pages/admin/files-settings.vue deleted file mode 100644 index 8aefa9e90d..0000000000 --- a/src/client/pages/admin/files-settings.vue +++ /dev/null @@ -1,93 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/files.vue b/src/client/pages/admin/files.vue deleted file mode 100644 index 55189cfd84..0000000000 --- a/src/client/pages/admin/files.vue +++ /dev/null @@ -1,209 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkDriveFileThumbnail from '@client/components/drive-file-thumbnail.vue'; -import bytes from '@client/filters/bytes'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/index.vue b/src/client/pages/admin/index.vue deleted file mode 100644 index 28157ff05a..0000000000 --- a/src/client/pages/admin/index.vue +++ /dev/null @@ -1,388 +0,0 @@ -<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 '@client/i18n'; -import MkSuperMenu from '@client/components/ui/super-menu.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import { scroll } from '@client/scripts/scroll'; -import { instance } from '@client/instance'; -import * as symbols from '@client/symbols'; -import * as os from '@client/os'; -import { lookupUser } from '@client/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/src/client/pages/admin/instance-block.vue b/src/client/pages/admin/instance-block.vue deleted file mode 100644 index 105cdb4941..0000000000 --- a/src/client/pages/admin/instance-block.vue +++ /dev/null @@ -1,72 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/instance.vue b/src/client/pages/admin/instance.vue deleted file mode 100644 index 5572fbbf75..0000000000 --- a/src/client/pages/admin/instance.vue +++ /dev/null @@ -1,321 +0,0 @@ -<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 '@client/components/ui/modal-window.vue'; -import MkUsersDialog from '@client/components/users-dialog.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import MkChart from '@client/components/chart.vue'; -import bytes from '@client/filters/bytes'; -import number from '@client/filters/number'; -import * as os from '@client/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/src/client/pages/admin/integrations-discord.vue b/src/client/pages/admin/integrations-discord.vue deleted file mode 100644 index c33b24f17f..0000000000 --- a/src/client/pages/admin/integrations-discord.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/integrations-github.vue b/src/client/pages/admin/integrations-github.vue deleted file mode 100644 index cdf85868ff..0000000000 --- a/src/client/pages/admin/integrations-github.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/integrations-twitter.vue b/src/client/pages/admin/integrations-twitter.vue deleted file mode 100644 index ed7d097d0a..0000000000 --- a/src/client/pages/admin/integrations-twitter.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/integrations.vue b/src/client/pages/admin/integrations.vue deleted file mode 100644 index bdc2cec4d0..0000000000 --- a/src/client/pages/admin/integrations.vue +++ /dev/null @@ -1,74 +0,0 @@ -<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 '@client/components/debobigego/link.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/metrics.vue b/src/client/pages/admin/metrics.vue deleted file mode 100644 index da36f6c688..0000000000 --- a/src/client/pages/admin/metrics.vue +++ /dev/null @@ -1,472 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import MkwFederation from '../../widgets/federation.vue'; -import { version, url } from '@client/config'; -import bytes from '@client/filters/bytes'; -import number from '@client/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 '@client/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/src/client/pages/admin/object-storage.vue b/src/client/pages/admin/object-storage.vue deleted file mode 100644 index 2d765270e6..0000000000 --- a/src/client/pages/admin/object-storage.vue +++ /dev/null @@ -1,155 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/other-settings.vue b/src/client/pages/admin/other-settings.vue deleted file mode 100644 index 4e55df41fb..0000000000 --- a/src/client/pages/admin/other-settings.vue +++ /dev/null @@ -1,83 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/overview.vue b/src/client/pages/admin/overview.vue deleted file mode 100644 index ced200351e..0000000000 --- a/src/client/pages/admin/overview.vue +++ /dev/null @@ -1,236 +0,0 @@ -<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 '@client/components/debobigego/key-value-view.vue'; -import MkInstanceStats from '@client/components/instance-stats.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkNumberDiff from '@client/components/number-diff.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import MkQueueChart from '@client/components/queue-chart.vue'; -import { version, url } from '@client/config'; -import bytes from '@client/filters/bytes'; -import number from '@client/filters/number'; -import MkInstanceInfo from './instance.vue'; -import XMetrics from './metrics.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/proxy-account.vue b/src/client/pages/admin/proxy-account.vue deleted file mode 100644 index b1ece19710..0000000000 --- a/src/client/pages/admin/proxy-account.vue +++ /dev/null @@ -1,87 +0,0 @@ -<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 '@client/components/debobigego/key-value-view.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/queue.chart.vue b/src/client/pages/admin/queue.chart.vue deleted file mode 100644 index 38cd0b10ef..0000000000 --- a/src/client/pages/admin/queue.chart.vue +++ /dev/null @@ -1,102 +0,0 @@ -<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 '@client/filters/number'; -import MkQueueChart from '@client/components/queue-chart.vue'; -import * as os from '@client/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/src/client/pages/admin/queue.vue b/src/client/pages/admin/queue.vue deleted file mode 100644 index f88825eb19..0000000000 --- a/src/client/pages/admin/queue.vue +++ /dev/null @@ -1,73 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import XQueue from './queue.chart.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/relays.vue b/src/client/pages/admin/relays.vue deleted file mode 100644 index 7d7888eaa8..0000000000 --- a/src/client/pages/admin/relays.vue +++ /dev/null @@ -1,99 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/admin/security.vue b/src/client/pages/admin/security.vue deleted file mode 100644 index 4365b6800c..0000000000 --- a/src/client/pages/admin/security.vue +++ /dev/null @@ -1,83 +0,0 @@ -<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 '@client/components/debobigego/link.vue'; -import FormSwitch from '@client/components/debobigego/switch.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/service-worker.vue b/src/client/pages/admin/service-worker.vue deleted file mode 100644 index 430e02ad2e..0000000000 --- a/src/client/pages/admin/service-worker.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/settings.vue b/src/client/pages/admin/settings.vue deleted file mode 100644 index 7bd363e5f3..0000000000 --- a/src/client/pages/admin/settings.vue +++ /dev/null @@ -1,151 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { fetchInstance } from '@client/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/src/client/pages/admin/users.vue b/src/client/pages/admin/users.vue deleted file mode 100644 index 37a54d2de3..0000000000 --- a/src/client/pages/admin/users.vue +++ /dev/null @@ -1,254 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import { acct } from '@client/filters/user'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { lookupUser } from '@client/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/src/client/pages/advanced-theme-editor.vue b/src/client/pages/advanced-theme-editor.vue deleted file mode 100644 index 8a63d74887..0000000000 --- a/src/client/pages/advanced-theme-editor.vue +++ /dev/null @@ -1,352 +0,0 @@ -<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 '@client/components/form/radio.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkSample from '@client/components/sample.vue'; - -import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@client/scripts/theme-editor'; -import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@client/scripts/theme'; -import { host } from '@client/config'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import { addTheme } from '@client/theme-store'; -import * as symbols from '@client/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/src/client/pages/announcements.vue b/src/client/pages/announcements.vue deleted file mode 100644 index 429d183d1e..0000000000 --- a/src/client/pages/announcements.vue +++ /dev/null @@ -1,74 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/antenna-timeline.vue b/src/client/pages/antenna-timeline.vue deleted file mode 100644 index c99124dbdc..0000000000 --- a/src/client/pages/antenna-timeline.vue +++ /dev/null @@ -1,147 +0,0 @@ -<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 '@client/scripts/loading'; -import XTimeline from '@client/components/timeline.vue'; -import { scroll } from '@client/scripts/scroll'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/api-console.vue b/src/client/pages/api-console.vue deleted file mode 100644 index 9aa7d4ea4d..0000000000 --- a/src/client/pages/api-console.vue +++ /dev/null @@ -1,93 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/auth.form.vue b/src/client/pages/auth.form.vue deleted file mode 100644 index 10c466c73c..0000000000 --- a/src/client/pages/auth.form.vue +++ /dev/null @@ -1,60 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import * as os from '@client/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/src/client/pages/auth.vue b/src/client/pages/auth.vue deleted file mode 100755 index 3656d48c42..0000000000 --- a/src/client/pages/auth.vue +++ /dev/null @@ -1,95 +0,0 @@ -<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 '@client/components/signin.vue'; -import * as os from '@client/os'; -import { login } from '@client/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/src/client/pages/channel-editor.vue b/src/client/pages/channel-editor.vue deleted file mode 100644 index 67e27896ce..0000000000 --- a/src/client/pages/channel-editor.vue +++ /dev/null @@ -1,129 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import { selectFile } from '@client/scripts/select-file'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/channel.vue b/src/client/pages/channel.vue deleted file mode 100644 index d725db9e49..0000000000 --- a/src/client/pages/channel.vue +++ /dev/null @@ -1,186 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import XPostForm from '@client/components/post-form.vue'; -import XTimeline from '@client/components/timeline.vue'; -import XChannelFollowButton from '@client/components/channel-follow-button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/channels.vue b/src/client/pages/channels.vue deleted file mode 100644 index fd1408c253..0000000000 --- a/src/client/pages/channels.vue +++ /dev/null @@ -1,77 +0,0 @@ -<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 '@client/components/channel-preview.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkTab from '@client/components/tab.vue'; -import * as symbols from '@client/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/src/client/pages/clip.vue b/src/client/pages/clip.vue deleted file mode 100644 index e4b00d5e28..0000000000 --- a/src/client/pages/clip.vue +++ /dev/null @@ -1,154 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import XPostForm from '@client/components/post-form.vue'; -import XNotes from '@client/components/notes.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/drive.vue b/src/client/pages/drive.vue deleted file mode 100644 index 9ee1ea8859..0000000000 --- a/src/client/pages/drive.vue +++ /dev/null @@ -1,28 +0,0 @@ -<template> -<div> - <XDrive ref="drive" @cd="x => folder = x"/> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import XDrive from '@client/components/drive.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/emojis.category.vue b/src/client/pages/emojis.category.vue deleted file mode 100644 index e725bcb31f..0000000000 --- a/src/client/pages/emojis.category.vue +++ /dev/null @@ -1,135 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import MkTab from '@client/components/tab.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { emojiCategories, emojiTags } from '@client/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/src/client/pages/emojis.emoji.vue b/src/client/pages/emojis.emoji.vue deleted file mode 100644 index ca0ef2dbb7..0000000000 --- a/src/client/pages/emojis.emoji.vue +++ /dev/null @@ -1,94 +0,0 @@ -<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 '@client/os'; -import copyToClipboard from '@client/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/src/client/pages/emojis.vue b/src/client/pages/emojis.vue deleted file mode 100644 index 8918de2338..0000000000 --- a/src/client/pages/emojis.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> -<div :class="$style.root"> - <XCategory v-if="tab === 'category'"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent, computed } from 'vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/explore.vue b/src/client/pages/explore.vue deleted file mode 100644 index 5a23d34d27..0000000000 --- a/src/client/pages/explore.vue +++ /dev/null @@ -1,261 +0,0 @@ -<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(/static-assets/client/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 '@client/components/user-list.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkRadios from '@client/components/form/radios.vue'; -import number from '@client/filters/number'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/favorites.vue b/src/client/pages/favorites.vue deleted file mode 100644 index f13723c2d1..0000000000 --- a/src/client/pages/favorites.vue +++ /dev/null @@ -1,60 +0,0 @@ -<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 '@client/scripts/loading'; -import XNotes from '@client/components/notes.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/featured.vue b/src/client/pages/featured.vue deleted file mode 100644 index 50df26bfb1..0000000000 --- a/src/client/pages/featured.vue +++ /dev/null @@ -1,43 +0,0 @@ -<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 '@client/scripts/loading'; -import XNotes from '@client/components/notes.vue'; -import * as symbols from '@client/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/src/client/pages/federation.vue b/src/client/pages/federation.vue deleted file mode 100644 index eae6a05367..0000000000 --- a/src/client/pages/federation.vue +++ /dev/null @@ -1,265 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/follow-requests.vue b/src/client/pages/follow-requests.vue deleted file mode 100644 index 6115dda454..0000000000 --- a/src/client/pages/follow-requests.vue +++ /dev/null @@ -1,153 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import { userPage, acct } from '@client/filters/user'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/follow.vue b/src/client/pages/follow.vue deleted file mode 100644 index d5247aff1e..0000000000 --- a/src/client/pages/follow.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> -<div class="mk-follow-page"> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; -import { parseAcct } from '@/misc/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', parseAcct(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/src/client/pages/gallery/edit.vue b/src/client/pages/gallery/edit.vue deleted file mode 100644 index 8e74b068ef..0000000000 --- a/src/client/pages/gallery/edit.vue +++ /dev/null @@ -1,168 +0,0 @@ -<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 '@client/components/debobigego/button.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormSwitch from '@client/components/debobigego/switch.vue'; -import FormTuple from '@client/components/debobigego/tuple.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import { selectFile } from '@client/scripts/select-file'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/gallery/index.vue b/src/client/pages/gallery/index.vue deleted file mode 100644 index ffc599513e..0000000000 --- a/src/client/pages/gallery/index.vue +++ /dev/null @@ -1,152 +0,0 @@ -<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 '@client/components/user-list.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkTab from '@client/components/tab.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue'; -import number from '@client/filters/number'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/gallery/post.vue b/src/client/pages/gallery/post.vue deleted file mode 100644 index dbac003e38..0000000000 --- a/src/client/pages/gallery/post.vue +++ /dev/null @@ -1,282 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import MkContainer from '@client/components/ui/container.vue'; -import ImgWithBlurhash from '@client/components/img-with-blurhash.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkGalleryPostPreview from '@client/components/gallery-post-preview.vue'; -import MkFollowButton from '@client/components/follow-button.vue'; -import { url } from '@client/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/src/client/pages/instance-info.vue b/src/client/pages/instance-info.vue deleted file mode 100644 index 291ceb5dfd..0000000000 --- a/src/client/pages/instance-info.vue +++ /dev/null @@ -1,238 +0,0 @@ -<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 '@client/components/chart.vue'; -import FormObjectView from '@client/components/debobigego/object-view.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import MkSelect from '@client/components/form/select.vue'; -import * as os from '@client/os'; -import number from '@client/filters/number'; -import bytes from '@client/filters/bytes'; -import * as symbols from '@client/symbols'; -import MkInstanceInfo from '@client/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/src/client/pages/mentions.vue b/src/client/pages/mentions.vue deleted file mode 100644 index 04682a856a..0000000000 --- a/src/client/pages/mentions.vue +++ /dev/null @@ -1,42 +0,0 @@ -<template> -<MkSpacer :content-max="800"> - <XNotes :pagination="pagination" @before="before()" @after="after()"/> -</MkSpacer> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import Progress from '@client/scripts/loading'; -import XNotes from '@client/components/notes.vue'; -import * as symbols from '@client/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/src/client/pages/messages.vue b/src/client/pages/messages.vue deleted file mode 100644 index e3d668cf45..0000000000 --- a/src/client/pages/messages.vue +++ /dev/null @@ -1,45 +0,0 @@ -<template> -<MkSpacer :content-max="800"> - <XNotes :pagination="pagination" @before="before()" @after="after()"/> -</MkSpacer> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import Progress from '@client/scripts/loading'; -import XNotes from '@client/components/notes.vue'; -import * as symbols from '@client/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/src/client/pages/messaging/index.vue b/src/client/pages/messaging/index.vue deleted file mode 100644 index 5b4fd51e55..0000000000 --- a/src/client/pages/messaging/index.vue +++ /dev/null @@ -1,307 +0,0 @@ -<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 { getAcct } from '@/misc/acct'; -import MkButton from '@client/components/ui/button.vue'; -import { acct } from '@client/filters/user'; -import * as os from '@client/os'; -import * as symbols from '@client/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, - - 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/${getAcct(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("/static-assets/client/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/src/client/pages/messaging/messaging-room.form.vue b/src/client/pages/messaging/messaging-room.form.vue deleted file mode 100644 index 31c42e4ab3..0000000000 --- a/src/client/pages/messaging/messaging-room.form.vue +++ /dev/null @@ -1,348 +0,0 @@ -<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 '@/misc/format-time-string'; -import { selectFile } from '@client/scripts/select-file'; -import * as os from '@client/os'; -import { Autocomplete } from '@client/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/src/client/pages/messaging/messaging-room.message.vue b/src/client/pages/messaging/messaging-room.message.vue deleted file mode 100644 index a2740c0bdc..0000000000 --- a/src/client/pages/messaging/messaging-room.message.vue +++ /dev/null @@ -1,350 +0,0 @@ -<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="/static-assets/client/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 '@/misc/extract-url-from-mfm'; -import MkUrlPreview from '@client/components/url-preview.vue'; -import * as os from '@client/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/src/client/pages/messaging/messaging-room.vue b/src/client/pages/messaging/messaging-room.vue deleted file mode 100644 index 76e58d5bc9..0000000000 --- a/src/client/pages/messaging/messaging-room.vue +++ /dev/null @@ -1,470 +0,0 @@ -<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 '@client/components/date-separated-list.vue'; -import XMessage from './messaging-room.message.vue'; -import XForm from './messaging-room.form.vue'; -import { parseAcct } from '@/misc/acct'; -import { isBottom, onScrollBottom, scroll } from '@client/scripts/scroll'; -import * as os from '@client/os'; -import { popout } from '@client/scripts/popout'; -import * as sound from '@client/scripts/sound'; -import * as symbols from '@client/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', parseAcct(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/src/client/pages/mfm-cheat-sheet.vue b/src/client/pages/mfm-cheat-sheet.vue deleted file mode 100644 index 5ff4317627..0000000000 --- a/src/client/pages/mfm-cheat-sheet.vue +++ /dev/null @@ -1,365 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import * as symbols from '@client/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/src/client/pages/miauth.vue b/src/client/pages/miauth.vue deleted file mode 100644 index 39cd832838..0000000000 --- a/src/client/pages/miauth.vue +++ /dev/null @@ -1,100 +0,0 @@ -<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 '@client/components/signin.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import { login } from '@client/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/src/client/pages/my-antennas/create.vue b/src/client/pages/my-antennas/create.vue deleted file mode 100644 index d4762411e7..0000000000 --- a/src/client/pages/my-antennas/create.vue +++ /dev/null @@ -1,51 +0,0 @@ -<template> -<div class="geegznzt"> - <XAntenna :antenna="draft" @created="onAntennaCreated"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import XAntenna from './editor.vue'; -import * as symbols from '@client/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/src/client/pages/my-antennas/edit.vue b/src/client/pages/my-antennas/edit.vue deleted file mode 100644 index 9deafb4235..0000000000 --- a/src/client/pages/my-antennas/edit.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<div class=""> - <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkButton from '@client/components/ui/button.vue'; -import XAntenna from './editor.vue'; -import * as symbols from '@client/symbols'; -import * as os from '@client/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/src/client/pages/my-antennas/editor.vue b/src/client/pages/my-antennas/editor.vue deleted file mode 100644 index 93ab640030..0000000000 --- a/src/client/pages/my-antennas/editor.vue +++ /dev/null @@ -1,190 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import { getAcct } from '@/misc/acct'; -import * as os from '@client/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@' + getAcct(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/src/client/pages/my-antennas/index.vue b/src/client/pages/my-antennas/index.vue deleted file mode 100644 index c27bb2c15e..0000000000 --- a/src/client/pages/my-antennas/index.vue +++ /dev/null @@ -1,71 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as symbols from '@client/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/src/client/pages/my-clips/index.vue b/src/client/pages/my-clips/index.vue deleted file mode 100644 index c4ca474748..0000000000 --- a/src/client/pages/my-clips/index.vue +++ /dev/null @@ -1,104 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/my-groups/group.vue b/src/client/pages/my-groups/group.vue deleted file mode 100644 index bd5537cbfa..0000000000 --- a/src/client/pages/my-groups/group.vue +++ /dev/null @@ -1,184 +0,0 @@ -<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 '@client/scripts/loading'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/my-groups/index.vue b/src/client/pages/my-groups/index.vue deleted file mode 100644 index 34f82f8a71..0000000000 --- a/src/client/pages/my-groups/index.vue +++ /dev/null @@ -1,121 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkAvatars from '@client/components/avatars.vue'; -import MkTab from '@client/components/tab.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/my-lists/index.vue b/src/client/pages/my-lists/index.vue deleted file mode 100644 index 687e9e630e..0000000000 --- a/src/client/pages/my-lists/index.vue +++ /dev/null @@ -1,88 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkAvatars from '@client/components/avatars.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/my-lists/list.vue b/src/client/pages/my-lists/list.vue deleted file mode 100644 index 049d370b4e..0000000000 --- a/src/client/pages/my-lists/list.vue +++ /dev/null @@ -1,170 +0,0 @@ -<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 '@client/scripts/loading'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/not-found.vue b/src/client/pages/not-found.vue deleted file mode 100644 index 5e7fe17f75..0000000000 --- a/src/client/pages/not-found.vue +++ /dev/null @@ -1,25 +0,0 @@ -<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 '@client/os'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.notFound, - icon: 'fas fa-exclamation-triangle' - }, - } - }, -}); -</script> diff --git a/src/client/pages/note.vue b/src/client/pages/note.vue deleted file mode 100644 index 8e95430d67..0000000000 --- a/src/client/pages/note.vue +++ /dev/null @@ -1,209 +0,0 @@ -<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 '@client/components/note.vue'; -import XNoteDetailed from '@client/components/note-detailed.vue'; -import XNotes from '@client/components/notes.vue'; -import MkRemoteCaution from '@client/components/remote-caution.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/notifications.vue b/src/client/pages/notifications.vue deleted file mode 100644 index 8d6adec48d..0000000000 --- a/src/client/pages/notifications.vue +++ /dev/null @@ -1,88 +0,0 @@ -<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 '@client/scripts/loading'; -import XNotifications from '@client/components/notifications.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { notificationTypes } from '@/types'; - -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/src/client/pages/page-editor/els/page-editor.el.button.vue b/src/client/pages/page-editor/els/page-editor.el.button.vue deleted file mode 100644 index 85e9d7e711..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.button.vue +++ /dev/null @@ -1,84 +0,0 @@ -<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 '@client/components/form/select.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.canvas.vue b/src/client/pages/page-editor/els/page-editor.el.canvas.vue deleted file mode 100644 index c40d69a7c1..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.canvas.vue +++ /dev/null @@ -1,50 +0,0 @@ -<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 '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.counter.vue b/src/client/pages/page-editor/els/page-editor.el.counter.vue deleted file mode 100644 index de7994e3ba..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.counter.vue +++ /dev/null @@ -1,46 +0,0 @@ -<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 '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.if.vue b/src/client/pages/page-editor/els/page-editor.el.if.vue deleted file mode 100644 index 52f4dac22e..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.if.vue +++ /dev/null @@ -1,84 +0,0 @@ -<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 '@client/components/form/select.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.image.vue b/src/client/pages/page-editor/els/page-editor.el.image.vue deleted file mode 100644 index d96879f50d..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.image.vue +++ /dev/null @@ -1,72 +0,0 @@ -<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 '@client/components/drive-file-thumbnail.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.note.vue b/src/client/pages/page-editor/els/page-editor.el.note.vue deleted file mode 100644 index 9feec395b7..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.note.vue +++ /dev/null @@ -1,65 +0,0 @@ -<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 '@client/components/form/input.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import XNote from '@client/components/note.vue'; -import XNoteDetailed from '@client/components/note-detailed.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.number-input.vue b/src/client/pages/page-editor/els/page-editor.el.number-input.vue deleted file mode 100644 index 57b1397824..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.number-input.vue +++ /dev/null @@ -1,46 +0,0 @@ -<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 '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.post.vue b/src/client/pages/page-editor/els/page-editor.el.post.vue deleted file mode 100644 index e21ccfd345..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.post.vue +++ /dev/null @@ -1,43 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.radio-button.vue b/src/client/pages/page-editor/els/page-editor.el.radio-button.vue deleted file mode 100644 index 62fb231f79..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.radio-button.vue +++ /dev/null @@ -1,50 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import MkInput from '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.section.vue b/src/client/pages/page-editor/els/page-editor.el.section.vue deleted file mode 100644 index 75bdf120c0..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.section.vue +++ /dev/null @@ -1,96 +0,0 @@ -<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 '@client/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/src/client/pages/page-editor/els/page-editor.el.switch.vue b/src/client/pages/page-editor/els/page-editor.el.switch.vue deleted file mode 100644 index cf15f58c82..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.switch.vue +++ /dev/null @@ -1,46 +0,0 @@ -<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 '@client/components/form/switch.vue'; -import MkInput from '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.text-input.vue b/src/client/pages/page-editor/els/page-editor.el.text-input.vue deleted file mode 100644 index 210199befd..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.text-input.vue +++ /dev/null @@ -1,39 +0,0 @@ -<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 '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.text.vue b/src/client/pages/page-editor/els/page-editor.el.text.vue deleted file mode 100644 index 668dd5f52d..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.text.vue +++ /dev/null @@ -1,57 +0,0 @@ -<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 '@client/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/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue b/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue deleted file mode 100644 index 14f36db2a1..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.textarea-input.vue +++ /dev/null @@ -1,40 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import MkInput from '@client/components/form/input.vue'; -import * as os from '@client/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/src/client/pages/page-editor/els/page-editor.el.textarea.vue b/src/client/pages/page-editor/els/page-editor.el.textarea.vue deleted file mode 100644 index a29d5bd3f2..0000000000 --- a/src/client/pages/page-editor/els/page-editor.el.textarea.vue +++ /dev/null @@ -1,57 +0,0 @@ -<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 '@client/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/src/client/pages/page-editor/page-editor.blocks.vue b/src/client/pages/page-editor/page-editor.blocks.vue deleted file mode 100644 index c27162a26e..0000000000 --- a/src/client/pages/page-editor/page-editor.blocks.vue +++ /dev/null @@ -1,78 +0,0 @@ -<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 '@client/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/src/client/pages/page-editor/page-editor.container.vue b/src/client/pages/page-editor/page-editor.container.vue deleted file mode 100644 index afd261fac7..0000000000 --- a/src/client/pages/page-editor/page-editor.container.vue +++ /dev/null @@ -1,159 +0,0 @@ -<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/src/client/pages/page-editor/page-editor.script-block.vue b/src/client/pages/page-editor/page-editor.script-block.vue deleted file mode 100644 index 3313fc1ba9..0000000000 --- a/src/client/pages/page-editor/page-editor.script-block.vue +++ /dev/null @@ -1,281 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import { blockDefs } from '@client/scripts/hpml/index'; -import * as os from '@client/os'; -import { isLiteralValue } from '@client/scripts/hpml/expr'; -import { funcDefs } from '@client/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/src/client/pages/page-editor/page-editor.vue b/src/client/pages/page-editor/page-editor.vue deleted file mode 100644 index aefcc14564..0000000000 --- a/src/client/pages/page-editor/page-editor.vue +++ /dev/null @@ -1,561 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkSelect from '@client/components/form/select.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import MkInput from '@client/components/form/input.vue'; -import { blockDefs } from '@client/scripts/hpml/index'; -import { HpmlTypeChecker } from '@client/scripts/hpml/type-checker'; -import { url } from '@client/config'; -import { collectPageVars } from '@client/scripts/collect-page-vars'; -import * as os from '@client/os'; -import { selectFile } from '@client/scripts/select-file'; -import * as symbols from '@client/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/src/client/pages/page.vue b/src/client/pages/page.vue deleted file mode 100644 index 3ea687a35d..0000000000 --- a/src/client/pages/page.vue +++ /dev/null @@ -1,311 +0,0 @@ -<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 '@client/components/page/page.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { url } from '@client/config'; -import MkFollowButton from '@client/components/follow-button.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkPagePreview from '@client/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/src/client/pages/pages.vue b/src/client/pages/pages.vue deleted file mode 100644 index 6963682592..0000000000 --- a/src/client/pages/pages.vue +++ /dev/null @@ -1,96 +0,0 @@ -<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 '@client/components/page-preview.vue'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import MkTab from '@client/components/tab.vue'; -import * as symbols from '@client/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/src/client/pages/preview.vue b/src/client/pages/preview.vue deleted file mode 100644 index 3df446e676..0000000000 --- a/src/client/pages/preview.vue +++ /dev/null @@ -1,32 +0,0 @@ -<template> -<div class="graojtoi"> - <MkSample/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import MkSample from '@client/components/sample.vue'; -import * as symbols from '@client/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/src/client/pages/reset-password.vue b/src/client/pages/reset-password.vue deleted file mode 100644 index 6dd9f24259..0000000000 --- a/src/client/pages/reset-password.vue +++ /dev/null @@ -1,69 +0,0 @@ -<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 '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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('@client/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/src/client/pages/reversi/game.board.vue b/src/client/pages/reversi/game.board.vue deleted file mode 100644 index 0dd36faced..0000000000 --- a/src/client/pages/reversi/game.board.vue +++ /dev/null @@ -1,528 +0,0 @@ -<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 '../../../games/reversi/core'; -import { url } from '@client/config'; -import MkButton from '@client/components/ui/button.vue'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import * as sound from '@client/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/src/client/pages/reversi/game.setting.vue b/src/client/pages/reversi/game.setting.vue deleted file mode 100644 index eb6f24e4ab..0000000000 --- a/src/client/pages/reversi/game.setting.vue +++ /dev/null @@ -1,390 +0,0 @@ -<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 '../../../games/reversi/maps'; -import MkButton from '@client/components/ui/button.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import MkRadio from '@client/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/src/client/pages/reversi/game.vue b/src/client/pages/reversi/game.vue deleted file mode 100644 index ae10b45b5b..0000000000 --- a/src/client/pages/reversi/game.vue +++ /dev/null @@ -1,76 +0,0 @@ -<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 '@client/os'; -import * as symbols from '@client/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/src/client/pages/reversi/index.vue b/src/client/pages/reversi/index.vue deleted file mode 100644 index cedfd12089..0000000000 --- a/src/client/pages/reversi/index.vue +++ /dev/null @@ -1,279 +0,0 @@ -<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 '@client/os'; -import MkButton from '@client/components/ui/button.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import * as symbols from '@client/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/src/client/pages/room/preview.vue b/src/client/pages/room/preview.vue deleted file mode 100644 index 0cb6bcf04c..0000000000 --- a/src/client/pages/room/preview.vue +++ /dev/null @@ -1,107 +0,0 @@ -<template> -<canvas width="224" height="128"></canvas> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as THREE from 'three'; -import * as os from '@client/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/src/client/pages/room/room.vue b/src/client/pages/room/room.vue deleted file mode 100644 index 671dca3577..0000000000 --- a/src/client/pages/room/room.vue +++ /dev/null @@ -1,285 +0,0 @@ -<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 '@client/scripts/room/room'; -import { parseAcct } from '@/misc/acct'; -import XPreview from './preview.vue'; -const storeItems = require('@client/scripts/room/furnitures.json5'); -import { query as urlQuery } from '../../../prelude/url'; -import MkButton from '@client/components/ui/button.vue'; -import MkSelect from '@client/components/form/select.vue'; -import { selectFile } from '@client/scripts/select-file'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import * as symbols from '@client/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', { - ...parseAcct(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/src/client/pages/scratchpad.vue b/src/client/pages/scratchpad.vue deleted file mode 100644 index 99164ec51f..0000000000 --- a/src/client/pages/scratchpad.vue +++ /dev/null @@ -1,149 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import MkButton from '@client/components/ui/button.vue'; -import { createAiScriptEnv } from '@client/scripts/aiscript/api'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/search.vue b/src/client/pages/search.vue deleted file mode 100644 index 8cf4d32a8f..0000000000 --- a/src/client/pages/search.vue +++ /dev/null @@ -1,53 +0,0 @@ -<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 '@client/scripts/loading'; -import XNotes from '@client/components/notes.vue'; -import * as symbols from '@client/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/src/client/pages/settings/2fa.vue b/src/client/pages/settings/2fa.vue deleted file mode 100644 index 386e7c635a..0000000000 --- a/src/client/pages/settings/2fa.vue +++ /dev/null @@ -1,247 +0,0 @@ -<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 '@client/config'; -import { byteify, hexify, stringify } from '@client/scripts/2fa'; -import MkButton from '@client/components/ui/button.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/account-info.vue b/src/client/pages/settings/account-info.vue deleted file mode 100644 index 16ce91b12f..0000000000 --- a/src/client/pages/settings/account-info.vue +++ /dev/null @@ -1,185 +0,0 @@ -<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 '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import * as os from '@client/os'; -import number from '@client/filters/number'; -import bytes from '@client/filters/bytes'; -import * as symbols from '@client/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/src/client/pages/settings/accounts.vue b/src/client/pages/settings/accounts.vue deleted file mode 100644 index d2966cc216..0000000000 --- a/src/client/pages/settings/accounts.vue +++ /dev/null @@ -1,149 +0,0 @@ -<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 '@client/components/debobigego/suspense.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { getAccounts, addAccount, login } from '@client/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('@client/components/signin-dialog.vue'), {}, { - done: res => { - addAccount(res.id, res.i); - os.success(); - }, - }, 'closed'); - }, - - createAccount() { - os.popup(import('@client/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/src/client/pages/settings/api.vue b/src/client/pages/settings/api.vue deleted file mode 100644 index 5c7496e2f9..0000000000 --- a/src/client/pages/settings/api.vue +++ /dev/null @@ -1,65 +0,0 @@ -<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 '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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('@client/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/src/client/pages/settings/apps.vue b/src/client/pages/settings/apps.vue deleted file mode 100644 index da4f672adf..0000000000 --- a/src/client/pages/settings/apps.vue +++ /dev/null @@ -1,113 +0,0 @@ -<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 '@client/components/debobigego/pagination.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/custom-css.vue b/src/client/pages/settings/custom-css.vue deleted file mode 100644 index fd473a11fa..0000000000 --- a/src/client/pages/settings/custom-css.vue +++ /dev/null @@ -1,73 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormRadios from '@client/components/form/radios.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import { unisonReload } from '@client/scripts/unison-reload'; -import * as symbols from '@client/symbols'; -import { defaultStore } from '@client/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/src/client/pages/settings/deck.vue b/src/client/pages/settings/deck.vue deleted file mode 100644 index e4b5c697c4..0000000000 --- a/src/client/pages/settings/deck.vue +++ /dev/null @@ -1,107 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormRadios from '@client/components/debobigego/radios.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import { deckStore } from '@client/ui/deck/deck-store'; -import * as os from '@client/os'; -import { unisonReload } from '@client/scripts/unison-reload'; -import * as symbols from '@client/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/src/client/pages/settings/delete-account.vue b/src/client/pages/settings/delete-account.vue deleted file mode 100644 index 6bac214e04..0000000000 --- a/src/client/pages/settings/delete-account.vue +++ /dev/null @@ -1,68 +0,0 @@ -<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 '@client/components/debobigego/info.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import { debug } from '@client/config'; -import { signout } from '@client/account'; -import * as symbols from '@client/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/src/client/pages/settings/drive.vue b/src/client/pages/settings/drive.vue deleted file mode 100644 index 2d73eb4df7..0000000000 --- a/src/client/pages/settings/drive.vue +++ /dev/null @@ -1,147 +0,0 @@ -<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 '@client/components/debobigego/button.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import * as os from '@client/os'; -import bytes from '@client/filters/bytes'; -import * as symbols from '@client/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/src/client/pages/settings/email-address.vue b/src/client/pages/settings/email-address.vue deleted file mode 100644 index f98b22ada7..0000000000 --- a/src/client/pages/settings/email-address.vue +++ /dev/null @@ -1,70 +0,0 @@ -<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 '@client/components/debobigego/button.vue'; -import FormInput from '@client/components/form/input.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/email-notification.vue b/src/client/pages/settings/email-notification.vue deleted file mode 100644 index 1b78621c3f..0000000000 --- a/src/client/pages/settings/email-notification.vue +++ /dev/null @@ -1,91 +0,0 @@ -<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 '@client/components/debobigego/button.vue'; -import FormSwitch from '@client/components/form/switch.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import * as symbols from '@client/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/src/client/pages/settings/email.vue b/src/client/pages/settings/email.vue deleted file mode 100644 index adc62133ac..0000000000 --- a/src/client/pages/settings/email.vue +++ /dev/null @@ -1,66 +0,0 @@ -<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 '@client/components/debobigego/button.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormSwitch from '@client/components/debobigego/switch.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/experimental-features.vue b/src/client/pages/settings/experimental-features.vue deleted file mode 100644 index 971c45a628..0000000000 --- a/src/client/pages/settings/experimental-features.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> -<FormBase> - <FormButton @click="error()">error test</FormButton> -</FormBase> -</template> - -<script lang="ts"> -import { defineAsyncComponent, defineComponent } from 'vue'; -import FormSwitch from '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue deleted file mode 100644 index 59dd251948..0000000000 --- a/src/client/pages/settings/general.vue +++ /dev/null @@ -1,223 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormSelect from '@client/components/debobigego/select.vue'; -import FormRadios from '@client/components/debobigego/radios.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import MkLink from '@client/components/link.vue'; -import { langs } from '@client/config'; -import { defaultStore } from '@client/store'; -import { ColdDeviceStorage } from '@client/store'; -import * as os from '@client/os'; -import { unisonReload } from '@client/scripts/unison-reload'; -import * as symbols from '@client/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/src/client/pages/settings/import-export.vue b/src/client/pages/settings/import-export.vue deleted file mode 100644 index eeaa1f1602..0000000000 --- a/src/client/pages/settings/import-export.vue +++ /dev/null @@ -1,112 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import FormSection from '@client/components/form/section.vue'; -import * as os from '@client/os'; -import { selectFile } from '@client/scripts/select-file'; -import * as symbols from '@client/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/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue deleted file mode 100644 index cf053dbe63..0000000000 --- a/src/client/pages/settings/index.vue +++ /dev/null @@ -1,326 +0,0 @@ -<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 '@client/i18n'; -import MkInfo from '@client/components/ui/info.vue'; -import MkSuperMenu from '@client/components/ui/super-menu.vue'; -import { scroll } from '@client/scripts/scroll'; -import { signout } from '@client/account'; -import { unisonReload } from '@client/scripts/unison-reload'; -import * as symbols from '@client/symbols'; -import { instance } from '@client/instance'; -import { $i } from '@client/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/src/client/pages/settings/integration.vue b/src/client/pages/settings/integration.vue deleted file mode 100644 index 7f398dde9d..0000000000 --- a/src/client/pages/settings/integration.vue +++ /dev/null @@ -1,141 +0,0 @@ -<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 '@client/config'; -import FormBase from '@client/components/debobigego/base.vue'; -import MkButton from '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/menu.vue b/src/client/pages/settings/menu.vue deleted file mode 100644 index 31472eb0c1..0000000000 --- a/src/client/pages/settings/menu.vue +++ /dev/null @@ -1,117 +0,0 @@ -<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 '@client/components/debobigego/textarea.vue'; -import FormRadios from '@client/components/debobigego/radios.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import { defaultStore } from '@client/store'; -import * as symbols from '@client/symbols'; -import { unisonReload } from '@client/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/src/client/pages/settings/mute-block.vue b/src/client/pages/settings/mute-block.vue deleted file mode 100644 index 18b2fc0af4..0000000000 --- a/src/client/pages/settings/mute-block.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import MkTab from '@client/components/tab.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/notifications.vue b/src/client/pages/settings/notifications.vue deleted file mode 100644 index 5f84349474..0000000000 --- a/src/client/pages/settings/notifications.vue +++ /dev/null @@ -1,77 +0,0 @@ -<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 '@client/components/debobigego/button.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import { notificationTypes } from '@/types'; -import * as os from '@client/os'; -import * as symbols from '@client/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('@client/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/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue deleted file mode 100644 index 2eb922453f..0000000000 --- a/src/client/pages/settings/other.vue +++ /dev/null @@ -1,97 +0,0 @@ -<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 '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import { debug } from '@client/config'; -import { defaultStore } from '@client/store'; -import { unisonReload } from '@client/scripts/unison-reload'; -import * as symbols from '@client/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('@client/components/taskmanager.vue'), { - }, {}, 'closed'); - }, - } -}); -</script> diff --git a/src/client/pages/settings/plugin.install.vue b/src/client/pages/settings/plugin.install.vue deleted file mode 100644 index 709ef11abb..0000000000 --- a/src/client/pages/settings/plugin.install.vue +++ /dev/null @@ -1,147 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormRadios from '@client/components/form/radios.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import { unisonReload } from '@client/scripts/unison-reload'; -import * as symbols from '@client/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('@client/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/src/client/pages/settings/plugin.manage.vue b/src/client/pages/settings/plugin.manage.vue deleted file mode 100644 index f1c27f1e3c..0000000000 --- a/src/client/pages/settings/plugin.manage.vue +++ /dev/null @@ -1,115 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkSelect from '@client/components/form/select.vue'; -import FormSwitch from '@client/components/form/switch.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import * as symbols from '@client/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/src/client/pages/settings/plugin.vue b/src/client/pages/settings/plugin.vue deleted file mode 100644 index 23f263bbbd..0000000000 --- a/src/client/pages/settings/plugin.vue +++ /dev/null @@ -1,44 +0,0 @@ -<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 '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import * as symbols from '@client/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/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue deleted file mode 100644 index 5e0c259ca3..0000000000 --- a/src/client/pages/settings/privacy.vue +++ /dev/null @@ -1,120 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormSelect from '@client/components/debobigego/select.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import * as os from '@client/os'; -import { defaultStore } from '@client/store'; -import * as symbols from '@client/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/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue deleted file mode 100644 index b993b5fc72..0000000000 --- a/src/client/pages/settings/profile.vue +++ /dev/null @@ -1,281 +0,0 @@ -<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 '@client/components/debobigego/button.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormSwitch from '@client/components/debobigego/switch.vue'; -import FormSelect from '@client/components/debobigego/select.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import { host, langs } from '@client/config'; -import { selectFile } from '@client/scripts/select-file'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue deleted file mode 100644 index a5ff46097d..0000000000 --- a/src/client/pages/settings/reaction.vue +++ /dev/null @@ -1,152 +0,0 @@ -<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 '@client/components/debobigego/input.vue'; -import FormRadios from '@client/components/debobigego/radios.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import * as os from '@client/os'; -import { defaultStore } from '@client/store'; -import * as symbols from '@client/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('@client/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/src/client/pages/settings/registry.keys.vue b/src/client/pages/settings/registry.keys.vue deleted file mode 100644 index d99002e50f..0000000000 --- a/src/client/pages/settings/registry.keys.vue +++ /dev/null @@ -1,114 +0,0 @@ -<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 '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/registry.value.vue b/src/client/pages/settings/registry.value.vue deleted file mode 100644 index 06be5737e9..0000000000 --- a/src/client/pages/settings/registry.value.vue +++ /dev/null @@ -1,149 +0,0 @@ -<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 '@client/components/debobigego/info.vue'; -import FormSwitch from '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormTextarea from '@client/components/form/textarea.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/registry.vue b/src/client/pages/settings/registry.vue deleted file mode 100644 index e4fb230d5c..0000000000 --- a/src/client/pages/settings/registry.vue +++ /dev/null @@ -1,90 +0,0 @@ -<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 '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue deleted file mode 100644 index e051685a82..0000000000 --- a/src/client/pages/settings/security.vue +++ /dev/null @@ -1,158 +0,0 @@ -<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 '@client/components/debobigego/base.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormPagination from '@client/components/debobigego/pagination.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/settings/sounds.vue b/src/client/pages/settings/sounds.vue deleted file mode 100644 index 07310619c8..0000000000 --- a/src/client/pages/settings/sounds.vue +++ /dev/null @@ -1,155 +0,0 @@ -<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 '@client/components/debobigego/range.vue'; -import FormSelect from '@client/components/debobigego/select.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import { playFile } from '@client/scripts/sound'; -import * as symbols from '@client/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/src/client/pages/settings/theme.install.vue b/src/client/pages/settings/theme.install.vue deleted file mode 100644 index 9fbb28929d..0000000000 --- a/src/client/pages/settings/theme.install.vue +++ /dev/null @@ -1,105 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormRadios from '@client/components/form/radios.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import { applyTheme, validateTheme } from '@client/scripts/theme'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import { addTheme, getThemes } from '@client/theme-store'; -import * as symbols from '@client/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/src/client/pages/settings/theme.manage.vue b/src/client/pages/settings/theme.manage.vue deleted file mode 100644 index 1a11a664f0..0000000000 --- a/src/client/pages/settings/theme.manage.vue +++ /dev/null @@ -1,105 +0,0 @@ -<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 '@client/components/debobigego/textarea.vue'; -import FormSelect from '@client/components/debobigego/select.vue'; -import FormRadios from '@client/components/debobigego/radios.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormInput from '@client/components/debobigego/input.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import { Theme, builtinThemes } from '@client/scripts/theme'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import { getThemes, removeTheme } from '@client/theme-store'; -import * as symbols from '@client/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/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue deleted file mode 100644 index c6be42251c..0000000000 --- a/src/client/pages/settings/theme.vue +++ /dev/null @@ -1,424 +0,0 @@ -<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 '@client/components/debobigego/switch.vue'; -import FormSelect from '@client/components/debobigego/select.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import { builtinThemes } from '@client/scripts/theme'; -import { selectFile } from '@client/scripts/select-file'; -import { isDeviceDarkmode } from '@client/scripts/is-device-darkmode'; -import { ColdDeviceStorage } from '@client/store'; -import { i18n } from '@client/i18n'; -import { defaultStore } from '@client/store'; -import { fetchThemes, getThemes } from '@client/theme-store'; -import * as symbols from '@client/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/src/client/pages/settings/update.vue b/src/client/pages/settings/update.vue deleted file mode 100644 index 8bc459e936..0000000000 --- a/src/client/pages/settings/update.vue +++ /dev/null @@ -1,95 +0,0 @@ -<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 '@client/components/form/switch.vue'; -import FormSelect from '@client/components/form/select.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import * as os from '@client/os'; -import { version, instanceName } from '@client/config'; -import * as symbols from '@client/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/src/client/pages/settings/word-mute.vue b/src/client/pages/settings/word-mute.vue deleted file mode 100644 index 53948b1b1e..0000000000 --- a/src/client/pages/settings/word-mute.vue +++ /dev/null @@ -1,110 +0,0 @@ -<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 '@client/components/form/textarea.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormInfo from '@client/components/debobigego/info.vue'; -import MkTab from '@client/components/tab.vue'; -import * as os from '@client/os'; -import number from '@client/filters/number'; -import * as symbols from '@client/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/src/client/pages/share.vue b/src/client/pages/share.vue deleted file mode 100644 index cb7347fae6..0000000000 --- a/src/client/pages/share.vue +++ /dev/null @@ -1,184 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import XPostForm from '@client/components/post-form.vue'; -import * as os from '@client/os'; -import { noteVisibilities } from '@/types'; -import { parseAcct } from '@/misc/acct'; -import * as symbols from '@client/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(parseAcct) : []) - ] - // 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/src/client/pages/signup-complete.vue b/src/client/pages/signup-complete.vue deleted file mode 100644 index dada92031a..0000000000 --- a/src/client/pages/signup-complete.vue +++ /dev/null @@ -1,50 +0,0 @@ -<template> -<div> - {{ $ts.processing }} -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import * as os from '@client/os'; -import * as symbols from '@client/symbols'; -import { login } from '@client/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/src/client/pages/tag.vue b/src/client/pages/tag.vue deleted file mode 100644 index 3ca9fe5c0c..0000000000 --- a/src/client/pages/tag.vue +++ /dev/null @@ -1,57 +0,0 @@ -<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 '@client/scripts/loading'; -import XNotes from '@client/components/notes.vue'; -import * as symbols from '@client/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/src/client/pages/test.vue b/src/client/pages/test.vue deleted file mode 100644 index fbab0112ed..0000000000 --- a/src/client/pages/test.vue +++ /dev/null @@ -1,259 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import MkSwitch from '@client/components/form/switch.vue'; -import MkTextarea from '@client/components/form/textarea.vue'; -import MkRadio from '@client/components/form/radio.vue'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/theme-editor.vue b/src/client/pages/theme-editor.vue deleted file mode 100644 index 3b10396ab8..0000000000 --- a/src/client/pages/theme-editor.vue +++ /dev/null @@ -1,306 +0,0 @@ -<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 '@client/components/debobigego/base.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; - -import { Theme, applyTheme, validateTheme, darkTheme, lightTheme } from '@client/scripts/theme'; -import { host } from '@client/config'; -import * as os from '@client/os'; -import { ColdDeviceStorage } from '@client/store'; -import { addTheme } from '@client/theme-store'; -import * as symbols from '@client/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/src/client/pages/timeline.tutorial.vue b/src/client/pages/timeline.tutorial.vue deleted file mode 100644 index 4d10289fbf..0000000000 --- a/src/client/pages/timeline.tutorial.vue +++ /dev/null @@ -1,131 +0,0 @@ -<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 '@client/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/src/client/pages/timeline.vue b/src/client/pages/timeline.vue deleted file mode 100644 index 7b17d585f8..0000000000 --- a/src/client/pages/timeline.vue +++ /dev/null @@ -1,225 +0,0 @@ -<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 '@client/scripts/loading'; -import XTimeline from '@client/components/timeline.vue'; -import XPostForm from '@client/components/post-form.vue'; -import { scroll } from '@client/scripts/scroll'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/user-ap-info.vue b/src/client/pages/user-ap-info.vue deleted file mode 100644 index cbdff874ed..0000000000 --- a/src/client/pages/user-ap-info.vue +++ /dev/null @@ -1,124 +0,0 @@ -<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 '@client/components/debobigego/object-view.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import number from '@client/filters/number'; -import bytes from '@client/filters/bytes'; -import * as symbols from '@client/symbols'; -import { url } from '@client/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/src/client/pages/user-info.vue b/src/client/pages/user-info.vue deleted file mode 100644 index bf67fc853a..0000000000 --- a/src/client/pages/user-info.vue +++ /dev/null @@ -1,245 +0,0 @@ -<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 '@client/components/debobigego/object-view.vue'; -import FormTextarea from '@client/components/debobigego/textarea.vue'; -import FormSwitch from '@client/components/debobigego/switch.vue'; -import FormLink from '@client/components/debobigego/link.vue'; -import FormBase from '@client/components/debobigego/base.vue'; -import FormGroup from '@client/components/debobigego/group.vue'; -import FormButton from '@client/components/debobigego/button.vue'; -import FormKeyValueView from '@client/components/debobigego/key-value-view.vue'; -import FormSuspense from '@client/components/debobigego/suspense.vue'; -import * as os from '@client/os'; -import number from '@client/filters/number'; -import bytes from '@client/filters/bytes'; -import * as symbols from '@client/symbols'; -import { url } from '@client/config'; -import { userPage, acct } from '@client/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/src/client/pages/user-list-timeline.vue b/src/client/pages/user-list-timeline.vue deleted file mode 100644 index b5e37d4843..0000000000 --- a/src/client/pages/user-list-timeline.vue +++ /dev/null @@ -1,147 +0,0 @@ -<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 '@client/scripts/loading'; -import XTimeline from '@client/components/timeline.vue'; -import { scroll } from '@client/scripts/scroll'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pages/user/clips.vue b/src/client/pages/user/clips.vue deleted file mode 100644 index 53ee554383..0000000000 --- a/src/client/pages/user/clips.vue +++ /dev/null @@ -1,50 +0,0 @@ -<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 '@client/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/src/client/pages/user/follow-list.vue b/src/client/pages/user/follow-list.vue deleted file mode 100644 index 1f5ab5993c..0000000000 --- a/src/client/pages/user/follow-list.vue +++ /dev/null @@ -1,65 +0,0 @@ -<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 '@client/components/user-info.vue'; -import MkPagination from '@client/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/src/client/pages/user/gallery.vue b/src/client/pages/user/gallery.vue deleted file mode 100644 index c21b3e6428..0000000000 --- a/src/client/pages/user/gallery.vue +++ /dev/null @@ -1,56 +0,0 @@ -<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 '@client/components/gallery-post-preview.vue'; -import MkPagination from '@client/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/src/client/pages/user/index.activity.vue b/src/client/pages/user/index.activity.vue deleted file mode 100644 index be85c252e8..0000000000 --- a/src/client/pages/user/index.activity.vue +++ /dev/null @@ -1,34 +0,0 @@ -<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 '@client/os'; -import MkContainer from '@client/components/ui/container.vue'; -import MkChart from '@client/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/src/client/pages/user/index.photos.vue b/src/client/pages/user/index.photos.vue deleted file mode 100644 index 5029c3feec..0000000000 --- a/src/client/pages/user/index.photos.vue +++ /dev/null @@ -1,107 +0,0 @@ -<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 '@client/scripts/get-static-image-url'; -import notePage from '@client/filters/note'; -import * as os from '@client/os'; -import MkContainer from '@client/components/ui/container.vue'; -import ImgWithBlurhash from '@client/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/src/client/pages/user/index.timeline.vue b/src/client/pages/user/index.timeline.vue deleted file mode 100644 index c3444f26f6..0000000000 --- a/src/client/pages/user/index.timeline.vue +++ /dev/null @@ -1,68 +0,0 @@ -<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 '@client/components/notes.vue'; -import MkTab from '@client/components/tab.vue'; -import * as os from '@client/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/src/client/pages/user/index.vue b/src/client/pages/user/index.vue deleted file mode 100644 index 04585f3fd0..0000000000 --- a/src/client/pages/user/index.vue +++ /dev/null @@ -1,829 +0,0 @@ -<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 '@client/components/note.vue'; -import MkFollowButton from '@client/components/follow-button.vue'; -import MkContainer from '@client/components/ui/container.vue'; -import MkFolder from '@client/components/ui/folder.vue'; -import MkRemoteCaution from '@client/components/remote-caution.vue'; -import MkTab from '@client/components/tab.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import Progress from '@client/scripts/loading'; -import { parseAcct } from '@/misc/acct'; -import { getScrollPosition } from '@client/scripts/scroll'; -import { getUserMenu } from '@client/scripts/get-user-menu'; -import number from '@client/filters/number'; -import { userPage, acct as getAcct } from '@client/filters/user'; -import * as os from '@client/os'; -import * as symbols from '@client/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', parseAcct(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/src/client/pages/user/pages.vue b/src/client/pages/user/pages.vue deleted file mode 100644 index ece418cf62..0000000000 --- a/src/client/pages/user/pages.vue +++ /dev/null @@ -1,49 +0,0 @@ -<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 '@client/components/page-preview.vue'; -import MkPagination from '@client/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/src/client/pages/user/reactions.vue b/src/client/pages/user/reactions.vue deleted file mode 100644 index 5ac7e01027..0000000000 --- a/src/client/pages/user/reactions.vue +++ /dev/null @@ -1,81 +0,0 @@ -<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 '@client/components/ui/pagination.vue'; -import MkNote from '@client/components/note.vue'; -import MkReactionIcon from '@client/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/src/client/pages/v.vue b/src/client/pages/v.vue deleted file mode 100644 index 4440e8070e..0000000000 --- a/src/client/pages/v.vue +++ /dev/null @@ -1,29 +0,0 @@ -<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 '@client/config'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - data() { - return { - [symbols.PAGE_INFO]: { - title: 'Misskey', - icon: null - }, - version, - } - }, -}); -</script> diff --git a/src/client/pages/welcome.entrance.a.vue b/src/client/pages/welcome.entrance.a.vue deleted file mode 100644 index 8e26682cf8..0000000000 --- a/src/client/pages/welcome.entrance.a.vue +++ /dev/null @@ -1,320 +0,0 @@ -<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="/static-assets/client/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 '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import MkButton from '@client/components/ui/button.vue'; -import XNote from '@client/components/note.vue'; -import MkFeaturedPhotos from '@client/components/featured-photos.vue'; -import XTimeline from './welcome.timeline.vue'; -import { host, instanceName } from '@client/config'; -import * as os from '@client/os'; -import number from '@client/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/src/client/pages/welcome.entrance.b.vue b/src/client/pages/welcome.entrance.b.vue deleted file mode 100644 index 9dc7289b4d..0000000000 --- a/src/client/pages/welcome.entrance.b.vue +++ /dev/null @@ -1,236 +0,0 @@ -<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="/static-assets/client/misskey.svg" class="misskey"/> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { toUnicode } from 'punycode/'; -import XSigninDialog from '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import MkButton from '@client/components/ui/button.vue'; -import XNote from '@client/components/note.vue'; -import MkFeaturedPhotos from '@client/components/featured-photos.vue'; -import XTimeline from './welcome.timeline.vue'; -import { host, instanceName } from '@client/config'; -import * as os from '@client/os'; -import number from '@client/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/src/client/pages/welcome.entrance.c.vue b/src/client/pages/welcome.entrance.c.vue deleted file mode 100644 index a946df86d2..0000000000 --- a/src/client/pages/welcome.entrance.c.vue +++ /dev/null @@ -1,305 +0,0 @@ -<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="/static-assets/client/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 '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import MkButton from '@client/components/ui/button.vue'; -import XNote from '@client/components/note.vue'; -import MkFeaturedPhotos from '@client/components/featured-photos.vue'; -import XTimeline from './welcome.timeline.vue'; -import { host, instanceName } from '@client/config'; -import * as os from '@client/os'; -import number from '@client/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/src/client/pages/welcome.setup.vue b/src/client/pages/welcome.setup.vue deleted file mode 100644 index dfefecc8fa..0000000000 --- a/src/client/pages/welcome.setup.vue +++ /dev/null @@ -1,102 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import MkInput from '@client/components/form/input.vue'; -import { host } from '@client/config'; -import * as os from '@client/os'; -import { login } from '@client/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/src/client/pages/welcome.timeline.vue b/src/client/pages/welcome.timeline.vue deleted file mode 100644 index bd07ac78db..0000000000 --- a/src/client/pages/welcome.timeline.vue +++ /dev/null @@ -1,99 +0,0 @@ -<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 '@client/components/reactions-viewer.vue'; -import XMediaList from '@client/components/media-list.vue'; -import XPoll from '@client/components/poll.vue'; -import * as os from '@client/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/src/client/pages/welcome.vue b/src/client/pages/welcome.vue deleted file mode 100644 index b6a715830d..0000000000 --- a/src/client/pages/welcome.vue +++ /dev/null @@ -1,38 +0,0 @@ -<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 '@client/config'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/pizzax.ts b/src/client/pizzax.ts deleted file mode 100644 index 396abc2418..0000000000 --- a/src/client/pizzax.ts +++ /dev/null @@ -1,153 +0,0 @@ -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/src/client/plugin.ts b/src/client/plugin.ts deleted file mode 100644 index 6bdc4fe4d5..0000000000 --- a/src/client/plugin.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 '@client/scripts/aiscript/api'; -import { dialog } from '@client/os'; -import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@client/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/src/client/router.ts b/src/client/router.ts deleted file mode 100644 index d57babffe5..0000000000 --- a/src/client/router.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { defineAsyncComponent, markRaw } from 'vue'; -import { createRouter, createWebHistory } from 'vue-router'; -import MkLoading from '@client/pages/_loading_.vue'; -import MkError from '@client/pages/_error_.vue'; -import MkTimeline from '@client/pages/timeline.vue'; -import { $i } from './account'; -import { ui } from '@client/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/src/client/scripts/2fa.ts b/src/client/scripts/2fa.ts deleted file mode 100644 index 00363cffa6..0000000000 --- a/src/client/scripts/2fa.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/src/client/scripts/aiscript/api.ts b/src/client/scripts/aiscript/api.ts deleted file mode 100644 index 5dd08f34ac..0000000000 --- a/src/client/scripts/aiscript/api.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { utils, values } from '@syuilo/aiscript'; -import * as os from '@client/os'; -import { $i } from '@client/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/src/client/scripts/autocomplete.ts b/src/client/scripts/autocomplete.ts deleted file mode 100644 index c0c33b2c7e..0000000000 --- a/src/client/scripts/autocomplete.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { Ref, ref } from 'vue'; -import * as getCaretCoordinates from 'textarea-caret'; -import { toASCII } from 'punycode/'; -import { popup } from '@client/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('@client/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/src/client/scripts/check-word-mute.ts b/src/client/scripts/check-word-mute.ts deleted file mode 100644 index 3b1fa75b1e..0000000000 --- a/src/client/scripts/check-word-mute.ts +++ /dev/null @@ -1,26 +0,0 @@ -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/src/client/scripts/collect-page-vars.ts b/src/client/scripts/collect-page-vars.ts deleted file mode 100644 index a4096fb2c2..0000000000 --- a/src/client/scripts/collect-page-vars.ts +++ /dev/null @@ -1,48 +0,0 @@ -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/src/client/scripts/contains.ts b/src/client/scripts/contains.ts deleted file mode 100644 index 770bda63bb..0000000000 --- a/src/client/scripts/contains.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/src/client/scripts/copy-to-clipboard.ts b/src/client/scripts/copy-to-clipboard.ts deleted file mode 100644 index ab13cab970..0000000000 --- a/src/client/scripts/copy-to-clipboard.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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/src/client/scripts/extract-avg-color-from-blurhash.ts b/src/client/scripts/extract-avg-color-from-blurhash.ts deleted file mode 100644 index 123ab7a06d..0000000000 --- a/src/client/scripts/extract-avg-color-from-blurhash.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/src/client/scripts/focus.ts b/src/client/scripts/focus.ts deleted file mode 100644 index 0894877820..0000000000 --- a/src/client/scripts/focus.ts +++ /dev/null @@ -1,27 +0,0 @@ -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/src/client/scripts/form.ts b/src/client/scripts/form.ts deleted file mode 100644 index 7bf6cec452..0000000000 --- a/src/client/scripts/form.ts +++ /dev/null @@ -1,31 +0,0 @@ -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/src/client/scripts/gen-search-query.ts b/src/client/scripts/gen-search-query.ts deleted file mode 100644 index cafb3cccfe..0000000000 --- a/src/client/scripts/gen-search-query.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { parseAcct } from '@/misc/acct'; -import { host as localHost } from '@client/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', parseAcct(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/src/client/scripts/get-account-from-id.ts b/src/client/scripts/get-account-from-id.ts deleted file mode 100644 index 065b41118c..0000000000 --- a/src/client/scripts/get-account-from-id.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { get } from '@client/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/src/client/scripts/get-md5.ts b/src/client/scripts/get-md5.ts deleted file mode 100644 index b002d762b1..0000000000 --- a/src/client/scripts/get-md5.ts +++ /dev/null @@ -1,10 +0,0 @@ -// スクリプトサイズがデカい -//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/src/client/scripts/get-static-image-url.ts b/src/client/scripts/get-static-image-url.ts deleted file mode 100644 index 92c31914c7..0000000000 --- a/src/client/scripts/get-static-image-url.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { url as instanceUrl } from '@client/config'; -import * as url from '../../prelude/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/src/client/scripts/get-user-menu.ts b/src/client/scripts/get-user-menu.ts deleted file mode 100644 index 3689a93b47..0000000000 --- a/src/client/scripts/get-user-menu.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { i18n } from '@client/i18n'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { host } from '@client/config'; -import { getAcct } from '@/misc/acct'; -import * as os from '@client/os'; -import { userActions } from '@client/store'; -import { router } from '@client/router'; -import { $i } from '@client/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('@client/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/' + getAcct(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/src/client/scripts/hotkey.ts b/src/client/scripts/hotkey.ts deleted file mode 100644 index 2b3f491fd8..0000000000 --- a/src/client/scripts/hotkey.ts +++ /dev/null @@ -1,88 +0,0 @@ -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/src/client/scripts/hpml/block.ts b/src/client/scripts/hpml/block.ts deleted file mode 100644 index 804c5c1124..0000000000 --- a/src/client/scripts/hpml/block.ts +++ /dev/null @@ -1,109 +0,0 @@ -// 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/src/client/scripts/hpml/evaluator.ts b/src/client/scripts/hpml/evaluator.ts deleted file mode 100644 index 68d140ff50..0000000000 --- a/src/client/scripts/hpml/evaluator.ts +++ /dev/null @@ -1,234 +0,0 @@ -import autobind from 'autobind-decorator'; -import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.'; -import { version } from '@client/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 '@client/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/src/client/scripts/hpml/expr.ts b/src/client/scripts/hpml/expr.ts deleted file mode 100644 index 00e3ed118b..0000000000 --- a/src/client/scripts/hpml/expr.ts +++ /dev/null @@ -1,79 +0,0 @@ -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/src/client/scripts/hpml/index.ts b/src/client/scripts/hpml/index.ts deleted file mode 100644 index ac81eac2d9..0000000000 --- a/src/client/scripts/hpml/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * 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/src/client/scripts/hpml/lib.ts b/src/client/scripts/hpml/lib.ts deleted file mode 100644 index 2a1ac73a40..0000000000 --- a/src/client/scripts/hpml/lib.ts +++ /dev/null @@ -1,246 +0,0 @@ -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/src/client/scripts/hpml/type-checker.ts b/src/client/scripts/hpml/type-checker.ts deleted file mode 100644 index 9633b3cd01..0000000000 --- a/src/client/scripts/hpml/type-checker.ts +++ /dev/null @@ -1,189 +0,0 @@ -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/src/client/scripts/idb-proxy.ts b/src/client/scripts/idb-proxy.ts deleted file mode 100644 index 5f76ae30bb..0000000000 --- a/src/client/scripts/idb-proxy.ts +++ /dev/null @@ -1,37 +0,0 @@ -// 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/src/client/scripts/initialize-sw.ts b/src/client/scripts/initialize-sw.ts deleted file mode 100644 index 6f1874572a..0000000000 --- a/src/client/scripts/initialize-sw.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { instance } from '@client/instance'; -import { $i } from '@client/account'; -import { api } from '@client/os'; -import { lang } from '@client/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/src/client/scripts/is-device-darkmode.ts b/src/client/scripts/is-device-darkmode.ts deleted file mode 100644 index 854f38e517..0000000000 --- a/src/client/scripts/is-device-darkmode.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isDeviceDarkmode() { - return window.matchMedia('(prefers-color-scheme: dark)').matches; -} diff --git a/src/client/scripts/is-device-touch.ts b/src/client/scripts/is-device-touch.ts deleted file mode 100644 index 3f0bfefed2..0000000000 --- a/src/client/scripts/is-device-touch.ts +++ /dev/null @@ -1 +0,0 @@ -export const isDeviceTouch = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; diff --git a/src/client/scripts/is-mobile.ts b/src/client/scripts/is-mobile.ts deleted file mode 100644 index 60cb59f91e..0000000000 --- a/src/client/scripts/is-mobile.ts +++ /dev/null @@ -1,2 +0,0 @@ -const ua = navigator.userAgent.toLowerCase(); -export const isMobile = /mobile|iphone|ipad|android/.test(ua); diff --git a/src/client/scripts/keycode.ts b/src/client/scripts/keycode.ts deleted file mode 100644 index c127d54bb2..0000000000 --- a/src/client/scripts/keycode.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/src/client/scripts/loading.ts b/src/client/scripts/loading.ts deleted file mode 100644 index 4b0a560e34..0000000000 --- a/src/client/scripts/loading.ts +++ /dev/null @@ -1,11 +0,0 @@ -export default { - start: () => { - // TODO - }, - done: () => { - // TODO - }, - set: val => { - // TODO - } -}; diff --git a/src/client/scripts/login-id.ts b/src/client/scripts/login-id.ts deleted file mode 100644 index 0f9c6be4a9..0000000000 --- a/src/client/scripts/login-id.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/src/client/scripts/lookup-user.ts b/src/client/scripts/lookup-user.ts deleted file mode 100644 index c393472ae8..0000000000 --- a/src/client/scripts/lookup-user.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { parseAcct } from '@/misc/acct'; -import { i18n } from '@client/i18n'; -import * as os from '@client/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', parseAcct(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/src/client/scripts/paging.ts b/src/client/scripts/paging.ts deleted file mode 100644 index 1da518efa1..0000000000 --- a/src/client/scripts/paging.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { markRaw } from 'vue'; -import * as os from '@client/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/src/client/scripts/physics.ts b/src/client/scripts/physics.ts deleted file mode 100644 index 445b6296eb..0000000000 --- a/src/client/scripts/physics.ts +++ /dev/null @@ -1,152 +0,0 @@ -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/src/client/scripts/please-login.ts b/src/client/scripts/please-login.ts deleted file mode 100644 index a584e9fa96..0000000000 --- a/src/client/scripts/please-login.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { $i } from '@client/account'; -import { i18n } from '@client/i18n'; -import { dialog } from '@client/os'; - -export function pleaseLogin() { - if ($i) return; - - dialog({ - title: i18n.locale.signinRequired, - text: null - }); - - throw new Error('signin required'); -} diff --git a/src/client/scripts/popout.ts b/src/client/scripts/popout.ts deleted file mode 100644 index 6d92af4a05..0000000000 --- a/src/client/scripts/popout.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as config from '@client/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/src/client/scripts/reaction-picker.ts b/src/client/scripts/reaction-picker.ts deleted file mode 100644 index 38699c0979..0000000000 --- a/src/client/scripts/reaction-picker.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Ref, ref } from 'vue'; -import { popup } from '@client/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('@client/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/src/client/scripts/room/furniture.ts b/src/client/scripts/room/furniture.ts deleted file mode 100644 index 7734e32668..0000000000 --- a/src/client/scripts/room/furniture.ts +++ /dev/null @@ -1,21 +0,0 @@ -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/src/client/scripts/room/furnitures.json5 b/src/client/scripts/room/furnitures.json5 deleted file mode 100644 index 4a40994107..0000000000 --- a/src/client/scripts/room/furnitures.json5 +++ /dev/null @@ -1,407 +0,0 @@ -// 家具メタデータ - -// 家具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/src/client/scripts/room/room.ts b/src/client/scripts/room/room.ts deleted file mode 100644 index 4450210c6c..0000000000 --- a/src/client/scripts/room/room.ts +++ /dev/null @@ -1,775 +0,0 @@ -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 '../../../prelude/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(`/static-assets/client/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(`/static-assets/client/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/src/client/scripts/scroll.ts b/src/client/scripts/scroll.ts deleted file mode 100644 index 621fe88105..0000000000 --- a/src/client/scripts/scroll.ts +++ /dev/null @@ -1,80 +0,0 @@ -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/src/client/scripts/search.ts b/src/client/scripts/search.ts deleted file mode 100644 index 2221f5f279..0000000000 --- a/src/client/scripts/search.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as os from '@client/os'; -import { i18n } from '@client/i18n'; -import { router } from '@client/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/src/client/scripts/select-file.ts b/src/client/scripts/select-file.ts deleted file mode 100644 index f7b971e113..0000000000 --- a/src/client/scripts/select-file.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as os from '@client/os'; -import { i18n } from '@client/i18n'; -import { defaultStore } from '@client/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/src/client/scripts/show-suspended-dialog.ts b/src/client/scripts/show-suspended-dialog.ts deleted file mode 100644 index dde829cdae..0000000000 --- a/src/client/scripts/show-suspended-dialog.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as os from '@client/os'; -import { i18n } from '@client/i18n'; - -export function showSuspendedDialog() { - return os.dialog({ - type: 'error', - title: i18n.locale.yourAccountSuspendedTitle, - text: i18n.locale.yourAccountSuspendedDescription - }); -} diff --git a/src/client/scripts/sound.ts b/src/client/scripts/sound.ts deleted file mode 100644 index c51fa8f215..0000000000 --- a/src/client/scripts/sound.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { ColdDeviceStorage } from '@client/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(`/static-assets/client/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/src/client/scripts/sticky-sidebar.ts b/src/client/scripts/sticky-sidebar.ts deleted file mode 100644 index c67b8f37ac..0000000000 --- a/src/client/scripts/sticky-sidebar.ts +++ /dev/null @@ -1,50 +0,0 @@ -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/src/client/scripts/theme-editor.ts b/src/client/scripts/theme-editor.ts deleted file mode 100644 index 3d69d2836a..0000000000 --- a/src/client/scripts/theme-editor.ts +++ /dev/null @@ -1,81 +0,0 @@ -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/src/client/scripts/theme.ts b/src/client/scripts/theme.ts deleted file mode 100644 index 8b63821293..0000000000 --- a/src/client/scripts/theme.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { globalEvents } from '@client/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('@client/themes/_light.json5'); -export const darkTheme: Theme = require('@client/themes/_dark.json5'); - -export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); - -export const builtinThemes = [ - require('@client/themes/l-light.json5'), - require('@client/themes/l-apricot.json5'), - require('@client/themes/l-rainy.json5'), - require('@client/themes/l-vivid.json5'), - require('@client/themes/l-sushi.json5'), - - require('@client/themes/d-dark.json5'), - require('@client/themes/d-persimmon.json5'), - require('@client/themes/d-astro.json5'), - require('@client/themes/d-future.json5'), - require('@client/themes/d-botanical.json5'), - require('@client/themes/d-pumpkin.json5'), - require('@client/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/src/client/scripts/unison-reload.ts b/src/client/scripts/unison-reload.ts deleted file mode 100644 index 59af584c1b..0000000000 --- a/src/client/scripts/unison-reload.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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/src/client/store.ts b/src/client/store.ts deleted file mode 100644 index eea3955fac..0000000000 --- a/src/client/store.ts +++ /dev/null @@ -1,318 +0,0 @@ -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('@client/themes/l-light.json5') as Theme, - darkTheme: require('@client/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/src/client/style.scss b/src/client/style.scss deleted file mode 100644 index 951d5a14f3..0000000000 --- a/src/client/style.scss +++ /dev/null @@ -1,562 +0,0 @@ -@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/src/client/sw/compose-notification.ts b/src/client/sw/compose-notification.ts deleted file mode 100644 index 7ed0a95359..0000000000 --- a/src/client/sw/compose-notification.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Notification composer of Service Worker - */ -declare var self: ServiceWorkerGlobalScope; - -import { getNoteSummary } from '@/misc/get-note-summary'; -import getUserName from '@/misc/get-user-name'; - -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/src/client/sw/sw.ts b/src/client/sw/sw.ts deleted file mode 100644 index 5be4eb9770..0000000000 --- a/src/client/sw/sw.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Service Worker - */ -declare var self: ServiceWorkerGlobalScope; - -import { get, set } from 'idb-keyval'; -import composeNotification from '@client/sw/compose-notification'; -import { I18n } from '@/misc/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/src/client/symbols.ts b/src/client/symbols.ts deleted file mode 100644 index 6913f29c28..0000000000 --- a/src/client/symbols.ts +++ /dev/null @@ -1 +0,0 @@ -export const PAGE_INFO = Symbol('Page info'); diff --git a/src/client/theme-store.ts b/src/client/theme-store.ts deleted file mode 100644 index f291069692..0000000000 --- a/src/client/theme-store.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { api } from '@client/os'; -import { $i } from '@client/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/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5 deleted file mode 100644 index d8be16f60a..0000000000 --- a/src/client/themes/_dark.json5 +++ /dev/null @@ -1,90 +0,0 @@ -// ダークテーマのベーステーマ -// このテーマが直接使われることは無い -{ - 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/src/client/themes/_light.json5 b/src/client/themes/_light.json5 deleted file mode 100644 index 251aa36c7a..0000000000 --- a/src/client/themes/_light.json5 +++ /dev/null @@ -1,90 +0,0 @@ -// ライトテーマのベーステーマ -// このテーマが直接使われることは無い -{ - 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/src/client/themes/d-astro.json5 b/src/client/themes/d-astro.json5 deleted file mode 100644 index c6a927ec3a..0000000000 --- a/src/client/themes/d-astro.json5 +++ /dev/null @@ -1,78 +0,0 @@ -{ - 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/src/client/themes/d-black.json5 b/src/client/themes/d-black.json5 deleted file mode 100644 index 3c18ebdaf1..0000000000 --- a/src/client/themes/d-black.json5 +++ /dev/null @@ -1,17 +0,0 @@ -{ - 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/src/client/themes/d-botanical.json5 b/src/client/themes/d-botanical.json5 deleted file mode 100644 index c03b95e2d7..0000000000 --- a/src/client/themes/d-botanical.json5 +++ /dev/null @@ -1,26 +0,0 @@ -{ - 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/src/client/themes/d-dark.json5 b/src/client/themes/d-dark.json5 deleted file mode 100644 index d24ce4df69..0000000000 --- a/src/client/themes/d-dark.json5 +++ /dev/null @@ -1,26 +0,0 @@ -{ - 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/src/client/themes/d-future.json5 b/src/client/themes/d-future.json5 deleted file mode 100644 index b6fa1ab0c1..0000000000 --- a/src/client/themes/d-future.json5 +++ /dev/null @@ -1,27 +0,0 @@ -{ - 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/src/client/themes/d-persimmon.json5 b/src/client/themes/d-persimmon.json5 deleted file mode 100644 index e36265ff10..0000000000 --- a/src/client/themes/d-persimmon.json5 +++ /dev/null @@ -1,25 +0,0 @@ -{ - 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/src/client/themes/d-pumpkin.json5 b/src/client/themes/d-pumpkin.json5 deleted file mode 100644 index 064ca4577b..0000000000 --- a/src/client/themes/d-pumpkin.json5 +++ /dev/null @@ -1,88 +0,0 @@ -{ - 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/src/client/themes/l-apricot.json5 b/src/client/themes/l-apricot.json5 deleted file mode 100644 index 1ed5525575..0000000000 --- a/src/client/themes/l-apricot.json5 +++ /dev/null @@ -1,22 +0,0 @@ -{ - 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/src/client/themes/l-light.json5 b/src/client/themes/l-light.json5 deleted file mode 100644 index 248355c945..0000000000 --- a/src/client/themes/l-light.json5 +++ /dev/null @@ -1,20 +0,0 @@ -{ - 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/src/client/themes/l-rainy.json5 b/src/client/themes/l-rainy.json5 deleted file mode 100644 index 283dd74c6c..0000000000 --- a/src/client/themes/l-rainy.json5 +++ /dev/null @@ -1,21 +0,0 @@ -{ - 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/src/client/themes/l-sushi.json5 b/src/client/themes/l-sushi.json5 deleted file mode 100644 index 5846927d65..0000000000 --- a/src/client/themes/l-sushi.json5 +++ /dev/null @@ -1,18 +0,0 @@ -{ - 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/src/client/themes/l-vivid.json5 b/src/client/themes/l-vivid.json5 deleted file mode 100644 index b3c08f38ae..0000000000 --- a/src/client/themes/l-vivid.json5 +++ /dev/null @@ -1,82 +0,0 @@ -{ - 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/src/client/tsconfig.json b/src/client/tsconfig.json deleted file mode 100644 index 7a26047ddf..0000000000 --- a/src/client/tsconfig.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "noEmitOnError": false, - "noImplicitAny": false, - "noImplicitReturns": true, - "noUnusedParameters": false, - "noUnusedLocals": true, - "noFallthroughCasesInSwitch": true, - "declaration": false, - "sourceMap": false, - "target": "es2017", - "module": "esnext", - "moduleResolution": "node", - "removeComments": false, - "noLib": false, - "strict": true, - "strictNullChecks": true, - "experimentalDecorators": true, - "resolveJsonModule": true, - "baseUrl": ".", - "paths": { - "@/*": ["../*"], - "@client/*": ["./*"], - "@lib/*": ["../../lib/*"], - }, - "typeRoots": [ - "node_modules/@types", - "src/@types", - "src/client/@types" - ], - "lib": [ - "esnext", - "dom", - "webworker" - ] - }, - "compileOnSave": false, - "include": [ - "./**/*.ts" - ] -} diff --git a/src/client/ui/_common_/common.vue b/src/client/ui/_common_/common.vue deleted file mode 100644 index 8da19a0984..0000000000 --- a/src/client/ui/_common_/common.vue +++ /dev/null @@ -1,89 +0,0 @@ -<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 '@client/os'; -import * as sound from '@client/scripts/sound'; -import { $i } from '@client/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('@client/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/src/client/ui/_common_/sidebar.vue b/src/client/ui/_common_/sidebar.vue deleted file mode 100644 index cd78b6ae46..0000000000 --- a/src/client/ui/_common_/sidebar.vue +++ /dev/null @@ -1,388 +0,0 @@ -<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 '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import { openAccountMenu } from '@client/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('@client/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/src/client/ui/_common_/stream-indicator.vue b/src/client/ui/_common_/stream-indicator.vue deleted file mode 100644 index 23f2357d85..0000000000 --- a/src/client/ui/_common_/stream-indicator.vue +++ /dev/null @@ -1,70 +0,0 @@ -<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 '@client/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/src/client/ui/_common_/upload.vue b/src/client/ui/_common_/upload.vue deleted file mode 100644 index 25a807cd36..0000000000 --- a/src/client/ui/_common_/upload.vue +++ /dev/null @@ -1,134 +0,0 @@ -<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 '@client/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/src/client/ui/chat/date-separated-list.vue b/src/client/ui/chat/date-separated-list.vue deleted file mode 100644 index 12638cd230..0000000000 --- a/src/client/ui/chat/date-separated-list.vue +++ /dev/null @@ -1,163 +0,0 @@ -<script lang="ts"> -import { defineComponent, h, PropType, TransitionGroup } from 'vue'; -import MkAd from '@client/components/global/ad.vue'; - -export default defineComponent({ - props: { - items: { - type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>, - required: true, - }, - 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/src/client/ui/chat/header-clock.vue b/src/client/ui/chat/header-clock.vue deleted file mode 100644 index 69ec3cb64b..0000000000 --- a/src/client/ui/chat/header-clock.vue +++ /dev/null @@ -1,62 +0,0 @@ -<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 '@client/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/src/client/ui/chat/index.vue b/src/client/ui/chat/index.vue deleted file mode 100644 index 4c068b0d94..0000000000 --- a/src/client/ui/chat/index.vue +++ /dev/null @@ -1,467 +0,0 @@ -<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 '@client/config'; -import XSidebar from '@client/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 '@client/os'; -import { router } from '@client/router'; -import { menuDef } from '@client/menu'; -import { search } from '@client/scripts/search'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { store } from './store'; -import * as symbols from '@client/symbols'; -import { openAccountMenu } from '@client/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/src/client/ui/chat/note-header.vue b/src/client/ui/chat/note-header.vue deleted file mode 100644 index e40f22f588..0000000000 --- a/src/client/ui/chat/note-header.vue +++ /dev/null @@ -1,112 +0,0 @@ -<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 '@client/filters/note'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; - -export default defineComponent({ - props: { - note: { - type: Object, - required: true - }, - }, - - data() { - return { - }; - }, - - methods: { - notePage, - userPage - } -}); -</script> - -<style lang="scss" scoped> -.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/src/client/ui/chat/note-preview.vue b/src/client/ui/chat/note-preview.vue deleted file mode 100644 index beb38de644..0000000000 --- a/src/client/ui/chat/note-preview.vue +++ /dev/null @@ -1,112 +0,0 @@ -<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 '@client/components/cw-button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XNoteHeader, - XSubNoteContent, - XCwButton, - }, - - props: { - note: { - type: Object, - required: true - } - }, - - data() { - return { - showContent: false - }; - } -}); -</script> - -<style lang="scss" scoped> -.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/src/client/ui/chat/note.sub.vue b/src/client/ui/chat/note.sub.vue deleted file mode 100644 index a284ba2bf4..0000000000 --- a/src/client/ui/chat/note.sub.vue +++ /dev/null @@ -1,137 +0,0 @@ -<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 '@client/components/cw-button.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - name: 'XSub', - - components: { - XNoteHeader, - XSubNoteContent, - XCwButton, - }, - - props: { - note: { - type: Object, - required: true - }, - detail: { - type: Boolean, - required: false, - default: false - }, - children: { - type: Boolean, - required: false, - default: false - }, - // TODO - truncate: { - type: Boolean, - default: true - } - }, - - data() { - return { - showContent: false, - replies: [], - }; - }, - - created() { - if (this.detail) { - os.api('notes/children', { - noteId: this.note.id, - limit: 5 - }).then(replies => { - this.replies = replies; - }); - } - }, -}); -</script> - -<style lang="scss" scoped> -.wrpstxzv { - padding: 16px 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/src/client/ui/chat/note.vue b/src/client/ui/chat/note.vue deleted file mode 100644 index 0a054d1057..0000000000 --- a/src/client/ui/chat/note.vue +++ /dev/null @@ -1,1144 +0,0 @@ -<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 '../../../prelude/array'; -import XSub from './note.sub.vue'; -import XNoteHeader from './note-header.vue'; -import XNoteSimple from './note-preview.vue'; -import XReactionsViewer from '@client/components/reactions-viewer.vue'; -import XMediaList from '@client/components/media-list.vue'; -import XCwButton from '@client/components/cw-button.vue'; -import XPoll from '@client/components/poll.vue'; -import { pleaseLogin } from '@client/scripts/please-login'; -import { focusPrev, focusNext } from '@client/scripts/focus'; -import { url } from '@client/config'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { checkWordMute } from '@client/scripts/check-word-mute'; -import { userPage } from '@client/filters/user'; -import * as os from '@client/os'; -import { noteActions, noteViewInterruptors } from '@client/store'; -import { reactionPicker } from '@client/scripts/reaction-picker'; -import { extractUrlFromMfm } from '@/misc/extract-url-from-mfm'; - -export default defineComponent({ - components: { - XSub, - XNoteHeader, - XNoteSimple, - XReactionsViewer, - XMediaList, - XCwButton, - XPoll, - MkUrlPreview: defineAsyncComponent(() => import('@client/components/url-preview.vue')), - MkInstanceTicker: defineAsyncComponent(() => import('@client/components/instance-ticker.vue')), - }, - - inject: { - inChannel: { - default: null - }, - }, - - props: { - note: { - type: Object, - required: true - }, - pinned: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['update:note'], - - data() { - return { - connection: null, - replies: [], - showContent: false, - collapsed: false, - isDeleted: false, - muted: false, - 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('@client/components/abuse-report-window.vue'), { - user: this.appearNote.user, - initialComment: `Note: ${u}\n-----\n` - }, {}, 'closed'); - } - }] - : [] - ), - ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [ - null, - this.appearNote.userId == this.$i.id ? { - icon: 'fas fa-edit', - text: this.$ts.deleteAndEdit, - action: this.delEdit - } : undefined, - { - icon: 'fas fa-trash-alt', - text: this.$ts.delete, - danger: true, - action: this.del - }] - : [] - )] - .filter(x => x !== undefined); - } else { - menu = [{ - icon: 'fas fa-copy', - text: this.$ts.copyContent, - action: this.copyContent - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: this.copyLink - }, (this.appearNote.url || this.appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: this.$ts.showOnRemote, - action: () => { - window.open(this.appearNote.url || this.appearNote.uri, '_blank'); - } - } : undefined] - .filter(x => x !== undefined); - } - - if (noteActions.length > 0) { - menu = menu.concat([null, ...noteActions.map(action => ({ - icon: 'fas fa-plug', - text: action.title, - action: () => { - action.handler(this.appearNote); - } - }))]); - } - - return menu; - }, - - onContextmenu(e) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(e.target)) return; - if (window.getSelection().toString() !== '') return; - - if (this.$store.state.useReactionPickerForContextMenu) { - e.preventDefault(); - this.react(); - } else { - os.contextMenu(this.getMenu(), e).then(this.focus); - } - }, - - menu(viaKeyboard = false) { - 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/src/client/ui/chat/notes.vue b/src/client/ui/chat/notes.vue deleted file mode 100644 index 6690baf584..0000000000 --- a/src/client/ui/chat/notes.vue +++ /dev/null @@ -1,94 +0,0 @@ -<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 '@client/scripts/paging'; -import XNote from './note.vue'; -import XList from './date-separated-list.vue'; -import MkButton from '@client/components/ui/button.vue'; - -export default defineComponent({ - components: { - XNote, XList, MkButton, - }, - - mixins: [ - paging({ - before: (self) => { - self.$emit('before'); - }, - - after: (self, e) => { - self.$emit('after', e); - } - }), - ], - - props: { - pagination: { - required: true - }, - - prop: { - type: String, - required: false - } - }, - - 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/src/client/ui/chat/pages/channel.vue b/src/client/ui/chat/pages/channel.vue deleted file mode 100644 index d11d40b210..0000000000 --- a/src/client/ui/chat/pages/channel.vue +++ /dev/null @@ -1,259 +0,0 @@ -<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 '@client/os'; -import * as sound from '@client/scripts/sound'; -import { scrollToBottom, getScrollPosition, getScrollContainer } from '@client/scripts/scroll'; -import follow from '@client/directives/follow-append'; -import XPostForm from '../post-form.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import * as symbols from '@client/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/src/client/ui/chat/pages/timeline.vue b/src/client/ui/chat/pages/timeline.vue deleted file mode 100644 index 0f9cd7f11e..0000000000 --- a/src/client/ui/chat/pages/timeline.vue +++ /dev/null @@ -1,221 +0,0 @@ -<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 '@client/os'; -import * as sound from '@client/scripts/sound'; -import { scrollToBottom, getScrollPosition, getScrollContainer } from '@client/scripts/scroll'; -import follow from '@client/directives/follow-append'; -import XPostForm from '../post-form.vue'; -import MkInfo from '@client/components/ui/info.vue'; -import * as symbols from '@client/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/src/client/ui/chat/post-form.vue b/src/client/ui/chat/post-form.vue deleted file mode 100644 index e1e56dee35..0000000000 --- a/src/client/ui/chat/post-form.vue +++ /dev/null @@ -1,773 +0,0 @@ -<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 '@client/config'; -import { erase, unique } from '../../../prelude/array'; -import { extractMentions } from '@/misc/extract-mentions'; -import { getAcct } from '@/misc/acct'; -import { formatTimeString } from '@/misc/format-time-string'; -import { Autocomplete } from '@client/scripts/autocomplete'; -import { noteVisibilities } from '../../../types'; -import * as os from '@client/os'; -import { selectFile } from '@client/scripts/select-file'; -import { notePostInterruptors, postFormActions } from '@client/store'; -import { isMobile } from '@client/scripts/is-mobile'; -import { throttle } from 'throttle-debounce'; - -export default defineComponent({ - components: { - XPostFormAttaches: defineAsyncComponent(() => import('@client/components/post-form-attaches.vue')), - XPollEditor: defineAsyncComponent(() => import('@client/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('@client/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, '@' + getAcct(user) + ' '); - }); - }, - - async insertEmoji(ev) { - os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text); - }, - - showActions(ev) { - os.popupMenu(postFormActions.map(action => ({ - text: action.title, - action: () => { - action.handler({ - text: this.text - }, (key, value) => { - if (key === 'text') { this.text = value; } - }); - } - })), ev.currentTarget || ev.target); - } - } -}); -</script> - -<style lang="scss" scoped> -.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/src/client/ui/chat/side.vue b/src/client/ui/chat/side.vue deleted file mode 100644 index a8a538c75b..0000000000 --- a/src/client/ui/chat/side.vue +++ /dev/null @@ -1,157 +0,0 @@ -<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 '@client/os'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - components: { - }, - - 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/src/client/ui/chat/store.ts b/src/client/ui/chat/store.ts deleted file mode 100644 index 389d56afb6..0000000000 --- a/src/client/ui/chat/store.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/src/client/ui/chat/sub-note-content.vue b/src/client/ui/chat/sub-note-content.vue deleted file mode 100644 index 8a3cf1160f..0000000000 --- a/src/client/ui/chat/sub-note-content.vue +++ /dev/null @@ -1,62 +0,0 @@ -<template> -<div class="wrmlmaau"> - <div class="body"> - <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> - <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span> - <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA> - <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/> - <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA> - </div> - <details v-if="note.files.length > 0"> - <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary> - <XMediaList :media-list="note.files"/> - </details> - <details v-if="note.poll"> - <summary>{{ $ts.poll }}</summary> - <XPoll :note="note"/> - </details> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XPoll from '@client/components/poll.vue'; -import XMediaList from '@client/components/media-list.vue'; -import * as os from '@client/os'; - -export default defineComponent({ - components: { - XPoll, - XMediaList, - }, - props: { - note: { - type: Object, - required: true - } - }, - data() { - return { - }; - } -}); -</script> - -<style lang="scss" scoped> -.wrmlmaau { - overflow-wrap: break-word; - - > .body { - > .reply { - margin-right: 6px; - color: var(--accent); - } - - > .rp { - margin-left: 4px; - font-style: oblique; - color: var(--renote); - } - } -} -</style> diff --git a/src/client/ui/chat/widgets.vue b/src/client/ui/chat/widgets.vue deleted file mode 100644 index 4d1865f616..0000000000 --- a/src/client/ui/chat/widgets.vue +++ /dev/null @@ -1,62 +0,0 @@ -<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 '@client/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/src/client/ui/classic.header.vue b/src/client/ui/classic.header.vue deleted file mode 100644 index ad5080e3f6..0000000000 --- a/src/client/ui/classic.header.vue +++ /dev/null @@ -1,210 +0,0 @@ -<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 '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import { openAccountMenu } from '@client/account'; -import MkButton from '@client/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('@client/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/src/client/ui/classic.side.vue b/src/client/ui/classic.side.vue deleted file mode 100644 index c7d2abff26..0000000000 --- a/src/client/ui/classic.side.vue +++ /dev/null @@ -1,158 +0,0 @@ -<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 '@client/os'; -import copyToClipboard from '@client/scripts/copy-to-clipboard'; -import { resolve } from '@client/router'; -import { url } from '@client/config'; -import * as symbols from '@client/symbols'; - -export default defineComponent({ - 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/src/client/ui/classic.sidebar.vue b/src/client/ui/classic.sidebar.vue deleted file mode 100644 index ac061d446b..0000000000 --- a/src/client/ui/classic.sidebar.vue +++ /dev/null @@ -1,263 +0,0 @@ -<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 '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import { openAccountMenu } from '@client/account'; -import MkButton from '@client/components/ui/button.vue'; -import { StickySidebar } from '@client/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('@client/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/src/client/ui/classic.vue b/src/client/ui/classic.vue deleted file mode 100644 index 79cdbe3af1..0000000000 --- a/src/client/ui/classic.vue +++ /dev/null @@ -1,471 +0,0 @@ -<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 '@client/config'; -import { StickySidebar } from '@client/scripts/sticky-sidebar'; -import XSidebar from './classic.sidebar.vue'; -import XDrawerSidebar from '@client/ui/_common_/sidebar.vue'; -import XCommon from './_common_/common.vue'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import * as symbols from '@client/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/src/client/ui/classic.widgets.vue b/src/client/ui/classic.widgets.vue deleted file mode 100644 index f9584402a2..0000000000 --- a/src/client/ui/classic.widgets.vue +++ /dev/null @@ -1,84 +0,0 @@ -<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 '@client/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/src/client/ui/deck.vue b/src/client/ui/deck.vue deleted file mode 100644 index 4b0189ba77..0000000000 --- a/src/client/ui/deck.vue +++ /dev/null @@ -1,229 +0,0 @@ -<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 '@client/config'; -import DeckColumnCore from '@client/ui/deck/column-core.vue'; -import XSidebar from '@client/ui/_common_/sidebar.vue'; -import { getScrollContainer } from '@client/scripts/scroll'; -import * as os from '@client/os'; -import { menuDef } from '@client/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/src/client/ui/deck/antenna-column.vue b/src/client/ui/deck/antenna-column.vue deleted file mode 100644 index 3abd3d3a45..0000000000 --- a/src/client/ui/deck/antenna-column.vue +++ /dev/null @@ -1,80 +0,0 @@ -<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 '@client/components/timeline.vue'; -import * as os from '@client/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/src/client/ui/deck/column-core.vue b/src/client/ui/deck/column-core.vue deleted file mode 100644 index 5393bac736..0000000000 --- a/src/client/ui/deck/column-core.vue +++ /dev/null @@ -1,52 +0,0 @@ -<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/src/client/ui/deck/column.vue b/src/client/ui/deck/column.vue deleted file mode 100644 index c04297e384..0000000000 --- a/src/client/ui/deck/column.vue +++ /dev/null @@ -1,408 +0,0 @@ -<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 '@client/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/src/client/ui/deck/deck-store.ts b/src/client/ui/deck/deck-store.ts deleted file mode 100644 index 6c61bf5539..0000000000 --- a/src/client/ui/deck/deck-store.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { throttle } from 'throttle-debounce'; -import { i18n } from '@client/i18n'; -import { api } from '@client/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/src/client/ui/deck/direct-column.vue b/src/client/ui/deck/direct-column.vue deleted file mode 100644 index 5b4b02932b..0000000000 --- a/src/client/ui/deck/direct-column.vue +++ /dev/null @@ -1,55 +0,0 @@ -<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 '@client/scripts/loading'; -import XColumn from './column.vue'; -import XNotes from '@client/components/notes.vue'; -import * as os from '@client/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/src/client/ui/deck/list-column.vue b/src/client/ui/deck/list-column.vue deleted file mode 100644 index 450280b863..0000000000 --- a/src/client/ui/deck/list-column.vue +++ /dev/null @@ -1,80 +0,0 @@ -<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 '@client/components/timeline.vue'; -import * as os from '@client/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/src/client/ui/deck/main-column.vue b/src/client/ui/deck/main-column.vue deleted file mode 100644 index 8e36caa3ce..0000000000 --- a/src/client/ui/deck/main-column.vue +++ /dev/null @@ -1,91 +0,0 @@ -<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 '@client/components/notes.vue'; -import { deckStore } from '@client/ui/deck/deck-store'; -import * as os from '@client/os'; -import * as symbols from '@client/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/src/client/ui/deck/mentions-column.vue b/src/client/ui/deck/mentions-column.vue deleted file mode 100644 index c625bb3ea1..0000000000 --- a/src/client/ui/deck/mentions-column.vue +++ /dev/null @@ -1,52 +0,0 @@ -<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 '@client/scripts/loading'; -import XColumn from './column.vue'; -import XNotes from '@client/components/notes.vue'; -import * as os from '@client/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/src/client/ui/deck/notifications-column.vue b/src/client/ui/deck/notifications-column.vue deleted file mode 100644 index c24bf7ab10..0000000000 --- a/src/client/ui/deck/notifications-column.vue +++ /dev/null @@ -1,53 +0,0 @@ -<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 '@client/components/notifications.vue'; -import * as os from '@client/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('@client/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/src/client/ui/deck/tl-column.vue b/src/client/ui/deck/tl-column.vue deleted file mode 100644 index 370f7d507f..0000000000 --- a/src/client/ui/deck/tl-column.vue +++ /dev/null @@ -1,137 +0,0 @@ -<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 '@client/components/timeline.vue'; -import * as os from '@client/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/src/client/ui/deck/widgets-column.vue b/src/client/ui/deck/widgets-column.vue deleted file mode 100644 index 22b1a38287..0000000000 --- a/src/client/ui/deck/widgets-column.vue +++ /dev/null @@ -1,71 +0,0 @@ -<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 '@client/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/src/client/ui/desktop.vue b/src/client/ui/desktop.vue deleted file mode 100644 index bff43e18b5..0000000000 --- a/src/client/ui/desktop.vue +++ /dev/null @@ -1,70 +0,0 @@ -<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 '@client/config'; -import { search } from '@client/scripts/search'; -import XCommon from './_common_/common.vue'; -import * as os from '@client/os'; -import XSidebar from '@client/ui/_common_/sidebar.vue'; -import { menuDef } from '@client/menu'; -import { ColdDeviceStorage } from '@client/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/src/client/ui/universal.vue b/src/client/ui/universal.vue deleted file mode 100644 index d8f1c2a934..0000000000 --- a/src/client/ui/universal.vue +++ /dev/null @@ -1,402 +0,0 @@ -<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 '@client/config'; -import { StickySidebar } from '@client/scripts/sticky-sidebar'; -import XSidebar from '@client/ui/_common_/sidebar.vue'; -import XCommon from './_common_/common.vue'; -import XSide from './classic.side.vue'; -import * as os from '@client/os'; -import { menuDef } from '@client/menu'; -import * as symbols from '@client/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/src/client/ui/universal.widgets.vue b/src/client/ui/universal.widgets.vue deleted file mode 100644 index 28b14749d1..0000000000 --- a/src/client/ui/universal.widgets.vue +++ /dev/null @@ -1,79 +0,0 @@ -<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 '@client/components/widgets.vue'; -import * as os from '@client/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/src/client/ui/visitor.vue b/src/client/ui/visitor.vue deleted file mode 100644 index ec9150d346..0000000000 --- a/src/client/ui/visitor.vue +++ /dev/null @@ -1,19 +0,0 @@ -<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/src/client/ui/visitor/a.vue b/src/client/ui/visitor/a.vue deleted file mode 100644 index ed015c6b07..0000000000 --- a/src/client/ui/visitor/a.vue +++ /dev/null @@ -1,260 +0,0 @@ -<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 '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import MkPagination from '@client/components/ui/pagination.vue'; -import MkButton from '@client/components/ui/button.vue'; -import XHeader from './header.vue'; -import { ColdDeviceStorage } from '@client/store'; -import * as symbols from '@client/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/src/client/ui/visitor/b.vue b/src/client/ui/visitor/b.vue deleted file mode 100644 index 0eefb3192a..0000000000 --- a/src/client/ui/visitor/b.vue +++ /dev/null @@ -1,282 +0,0 @@ -<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 '@client/config'; -import { search } from '@client/scripts/search'; -import * as os from '@client/os'; -import MkPagination from '@client/components/ui/pagination.vue'; -import XSigninDialog from '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import MkButton from '@client/components/ui/button.vue'; -import XHeader from './header.vue'; -import XKanban from './kanban.vue'; -import { ColdDeviceStorage } from '@client/store'; -import * as symbols from '@client/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/src/client/ui/visitor/header.vue b/src/client/ui/visitor/header.vue deleted file mode 100644 index 6d2ebc880f..0000000000 --- a/src/client/ui/visitor/header.vue +++ /dev/null @@ -1,228 +0,0 @@ -<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 '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import * as os from '@client/os'; -import { search } from '@client/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/src/client/ui/visitor/kanban.vue b/src/client/ui/visitor/kanban.vue deleted file mode 100644 index 5fbbff3d32..0000000000 --- a/src/client/ui/visitor/kanban.vue +++ /dev/null @@ -1,256 +0,0 @@ -<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 '@client/config'; -import * as os from '@client/os'; -import MkPagination from '@client/components/ui/pagination.vue'; -import XSigninDialog from '@client/components/signin-dialog.vue'; -import XSignupDialog from '@client/components/signup-dialog.vue'; -import MkButton from '@client/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/src/client/ui/zen.vue b/src/client/ui/zen.vue deleted file mode 100644 index ebbf72bca7..0000000000 --- a/src/client/ui/zen.vue +++ /dev/null @@ -1,106 +0,0 @@ -<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 '@client/config'; -import XCommon from './_common_/common.vue'; -import * as symbols from '@client/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/src/client/widgets/activity.calendar.vue b/src/client/widgets/activity.calendar.vue deleted file mode 100644 index ff767190f6..0000000000 --- a/src/client/widgets/activity.calendar.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/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/src/client/widgets/activity.chart.vue b/src/client/widgets/activity.chart.vue deleted file mode 100644 index ee5bc25113..0000000000 --- a/src/client/widgets/activity.chart.vue +++ /dev/null @@ -1,107 +0,0 @@ -<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 '@client/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/src/client/widgets/activity.vue b/src/client/widgets/activity.vue deleted file mode 100644 index cc8d4debd0..0000000000 --- a/src/client/widgets/activity.vue +++ /dev/null @@ -1,82 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import define from './define'; -import XCalendar from './activity.calendar.vue'; -import XChart from './activity.chart.vue'; -import * as os from '@client/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/src/client/widgets/aichan.vue b/src/client/widgets/aichan.vue deleted file mode 100644 index 06c49090a1..0000000000 --- a/src/client/widgets/aichan.vue +++ /dev/null @@ -1,59 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import * as os from '@client/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/src/client/widgets/aiscript.vue b/src/client/widgets/aiscript.vue deleted file mode 100644 index aaf0a0372e..0000000000 --- a/src/client/widgets/aiscript.vue +++ /dev/null @@ -1,163 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import define from './define'; -import * as os from '@client/os'; -import { AiScript, parse, utils } from '@syuilo/aiscript'; -import { createAiScriptEnv } from '@client/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/src/client/widgets/button.vue b/src/client/widgets/button.vue deleted file mode 100644 index af6718c507..0000000000 --- a/src/client/widgets/button.vue +++ /dev/null @@ -1,95 +0,0 @@ -<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 '@client/components/ui/button.vue'; -import * as os from '@client/os'; -import { AiScript, parse, utils } from '@syuilo/aiscript'; -import { createAiScriptEnv } from '@client/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/src/client/widgets/calendar.vue b/src/client/widgets/calendar.vue deleted file mode 100644 index fe39145f0d..0000000000 --- a/src/client/widgets/calendar.vue +++ /dev/null @@ -1,204 +0,0 @@ -<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 '@client/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/src/client/widgets/clock.vue b/src/client/widgets/clock.vue deleted file mode 100644 index d960c3809a..0000000000 --- a/src/client/widgets/clock.vue +++ /dev/null @@ -1,55 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import MkAnalogClock from '@client/components/analog-clock.vue'; -import * as os from '@client/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/src/client/widgets/define.ts b/src/client/widgets/define.ts deleted file mode 100644 index 22b7fb30a1..0000000000 --- a/src/client/widgets/define.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { defineComponent } from 'vue'; -import { throttle } from 'throttle-debounce'; -import { Form } from '@client/scripts/form'; -import * as os from '@client/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/src/client/widgets/digital-clock.vue b/src/client/widgets/digital-clock.vue deleted file mode 100644 index 2202c9ed4b..0000000000 --- a/src/client/widgets/digital-clock.vue +++ /dev/null @@ -1,79 +0,0 @@ -<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 '@client/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/src/client/widgets/federation.vue b/src/client/widgets/federation.vue deleted file mode 100644 index 8ab7f594a2..0000000000 --- a/src/client/widgets/federation.vue +++ /dev/null @@ -1,145 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import define from './define'; -import MkMiniChart from '@client/components/mini-chart.vue'; -import * as os from '@client/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/src/client/widgets/index.ts b/src/client/widgets/index.ts deleted file mode 100644 index 51a82af080..0000000000 --- a/src/client/widgets/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -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/src/client/widgets/job-queue.vue b/src/client/widgets/job-queue.vue deleted file mode 100644 index 327d8ede6d..0000000000 --- a/src/client/widgets/job-queue.vue +++ /dev/null @@ -1,183 +0,0 @@ -<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 '@client/os'; -import number from '@client/filters/number'; -import * as sound from '@client/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/src/client/widgets/memo.vue b/src/client/widgets/memo.vue deleted file mode 100644 index 3f11e6409e..0000000000 --- a/src/client/widgets/memo.vue +++ /dev/null @@ -1,106 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import define from './define'; -import * as os from '@client/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/src/client/widgets/notifications.vue b/src/client/widgets/notifications.vue deleted file mode 100644 index 5e2648f5b9..0000000000 --- a/src/client/widgets/notifications.vue +++ /dev/null @@ -1,65 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import XNotifications from '@client/components/notifications.vue'; -import define from './define'; -import * as os from '@client/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('@client/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/src/client/widgets/online-users.vue b/src/client/widgets/online-users.vue deleted file mode 100644 index 37060fca43..0000000000 --- a/src/client/widgets/online-users.vue +++ /dev/null @@ -1,67 +0,0 @@ -<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 '@client/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/src/client/widgets/photos.vue b/src/client/widgets/photos.vue deleted file mode 100644 index 25365d6b87..0000000000 --- a/src/client/widgets/photos.vue +++ /dev/null @@ -1,113 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import define from './define'; -import { getStaticImageUrl } from '@client/scripts/get-static-image-url'; -import * as os from '@client/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/src/client/widgets/post-form.vue b/src/client/widgets/post-form.vue deleted file mode 100644 index 1f260c20d9..0000000000 --- a/src/client/widgets/post-form.vue +++ /dev/null @@ -1,23 +0,0 @@ -<template> -<XPostForm class="_panel" :fixed="true" :autofocus="false"/> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XPostForm from '@client/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/src/client/widgets/rss.vue b/src/client/widgets/rss.vue deleted file mode 100644 index 6d19a86dff..0000000000 --- a/src/client/widgets/rss.vue +++ /dev/null @@ -1,89 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import define from './define'; -import * as os from '@client/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/src/client/widgets/server-metric/cpu-mem.vue b/src/client/widgets/server-metric/cpu-mem.vue deleted file mode 100644 index ad9e6a8b0f..0000000000 --- a/src/client/widgets/server-metric/cpu-mem.vue +++ /dev/null @@ -1,174 +0,0 @@ -<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/src/client/widgets/server-metric/cpu.vue b/src/client/widgets/server-metric/cpu.vue deleted file mode 100644 index 4478ee3065..0000000000 --- a/src/client/widgets/server-metric/cpu.vue +++ /dev/null @@ -1,76 +0,0 @@ -<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/src/client/widgets/server-metric/disk.vue b/src/client/widgets/server-metric/disk.vue deleted file mode 100644 index a3f5d0376b..0000000000 --- a/src/client/widgets/server-metric/disk.vue +++ /dev/null @@ -1,70 +0,0 @@ -<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 '@client/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/src/client/widgets/server-metric/index.vue b/src/client/widgets/server-metric/index.vue deleted file mode 100644 index 45cd8cebf2..0000000000 --- a/src/client/widgets/server-metric/index.vue +++ /dev/null @@ -1,82 +0,0 @@ -<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 '@client/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 '@client/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/src/client/widgets/server-metric/mem.vue b/src/client/widgets/server-metric/mem.vue deleted file mode 100644 index 92c0aa0c77..0000000000 --- a/src/client/widgets/server-metric/mem.vue +++ /dev/null @@ -1,85 +0,0 @@ -<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 '@client/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/src/client/widgets/server-metric/net.vue b/src/client/widgets/server-metric/net.vue deleted file mode 100644 index 569c15b58b..0000000000 --- a/src/client/widgets/server-metric/net.vue +++ /dev/null @@ -1,148 +0,0 @@ -<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 '@client/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/src/client/widgets/server-metric/pie.vue b/src/client/widgets/server-metric/pie.vue deleted file mode 100644 index 38dcf6fcd9..0000000000 --- a/src/client/widgets/server-metric/pie.vue +++ /dev/null @@ -1,65 +0,0 @@ -<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/src/client/widgets/slideshow.vue b/src/client/widgets/slideshow.vue deleted file mode 100644 index 2f079e0d42..0000000000 --- a/src/client/widgets/slideshow.vue +++ /dev/null @@ -1,167 +0,0 @@ -<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 '@client/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/src/client/widgets/timeline.vue b/src/client/widgets/timeline.vue deleted file mode 100644 index bd951d8565..0000000000 --- a/src/client/widgets/timeline.vue +++ /dev/null @@ -1,116 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import XTimeline from '@client/components/timeline.vue'; -import define from './define'; -import * as os from '@client/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/src/client/widgets/trends.vue b/src/client/widgets/trends.vue deleted file mode 100644 index 8511bc718f..0000000000 --- a/src/client/widgets/trends.vue +++ /dev/null @@ -1,111 +0,0 @@ -<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 '@client/components/ui/container.vue'; -import define from './define'; -import MkMiniChart from '@client/components/mini-chart.vue'; -import * as os from '@client/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> |