diff options
Diffstat (limited to 'src/client/app/common')
61 files changed, 1081 insertions, 556 deletions
diff --git a/src/client/app/common/define-widget.ts b/src/client/app/common/define-widget.ts index 56314a4104..5eb9718446 100644 --- a/src/client/app/common/define-widget.ts +++ b/src/client/app/common/define-widget.ts @@ -1,6 +1,6 @@ import Vue from 'vue'; -export default function<T extends object>(data: { +export default function <T extends object>(data: { name: string; props?: () => T; }) { @@ -53,11 +53,10 @@ export default function<T extends object>(data: { mergeProps() { if (data.props) { const defaultProps = data.props(); - Object.keys(defaultProps).forEach(prop => { - if (!this.props.hasOwnProperty(prop)) { - Vue.set(this.props, prop, defaultProps[prop]); - } - }); + for (const prop of Object.keys(defaultProps)) { + if (this.props.hasOwnProperty(prop)) continue; + Vue.set(this.props, prop, defaultProps[prop]); + } } }, diff --git a/src/client/app/common/hotkey.ts b/src/client/app/common/hotkey.ts index f7366e35cb..b2afd57ae3 100644 --- a/src/client/app/common/hotkey.ts +++ b/src/client/app/common/hotkey.ts @@ -28,15 +28,15 @@ const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): a shift: false } as pattern; - part.trim().split('+').forEach(key => { - key = key.trim().toLowerCase(); + 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; }); @@ -77,11 +77,7 @@ export default { const matched = match(e, action.patterns); if (matched) { - if (el._hotkey_global) { - if (match(e, targetReservedKeys)) { - return; - } - } + if (el._hotkey_global && match(e, targetReservedKeys)) return; e.preventDefault(); e.stopPropagation(); diff --git a/src/client/app/common/scripts/check-for-update.ts b/src/client/app/common/scripts/check-for-update.ts index 7fe9d8d50c..20da83a0c2 100644 --- a/src/client/app/common/scripts/check-for-update.ts +++ b/src/client/app/common/scripts/check-for-update.ts @@ -14,19 +14,20 @@ export default async function($root: any, force = false, silent = false) { navigator.serviceWorker.controller.postMessage('clear'); } - navigator.serviceWorker.getRegistrations().then(registrations => { - registrations.forEach(registration => registration.unregister()); - }); + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + registration.unregister(); + } } catch (e) { console.error(e); } - if (!silent) { - $root.alert({ + /*if (!silent) { + $root.dialog({ title: $root.$t('@.update-available-title'), text: $root.$t('@.update-available', { newer, current }) }); - } + }*/ return newer; } else { diff --git a/src/client/app/common/scripts/fuck-ad-block.ts b/src/client/app/common/scripts/fuck-ad-block.ts index f5cc1b71f2..ba7e5a9f87 100644 --- a/src/client/app/common/scripts/fuck-ad-block.ts +++ b/src/client/app/common/scripts/fuck-ad-block.ts @@ -4,7 +4,7 @@ export default ($root: any) => { require('fuckadblock'); function adBlockDetected() { - $root.alert({ + $root.dialog({ title: $root.$t('@.adblock.detected'), text: $root.$t('@.adblock.warning') }); diff --git a/src/client/app/common/scripts/get-face.ts b/src/client/app/common/scripts/get-face.ts index 79cf7a1be4..b523948bd3 100644 --- a/src/client/app/common/scripts/get-face.ts +++ b/src/client/app/common/scripts/get-face.ts @@ -2,7 +2,7 @@ const faces = [ '(=^・・^=)', 'v(\'ω\')v', '🐡( \'-\' 🐡 )フグパンチ!!!!', - '🖕(´・_・`)🖕', + '✌️(´・_・`)✌️', '(。>﹏<。)', '(Δ・x・Δ)' ]; diff --git a/src/client/app/common/scripts/note-mixin.ts b/src/client/app/common/scripts/note-mixin.ts index e0df788b34..36b8ca32c1 100644 --- a/src/client/app/common/scripts/note-mixin.ts +++ b/src/client/app/common/scripts/note-mixin.ts @@ -78,9 +78,10 @@ export default (opts: Opts = {}) => ({ urls(): string[] { if (this.appearNote.text) { const ast = parse(this.appearNote.text); + // TODO: 再帰的にURL要素がないか調べる return unique(ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url)); + .filter(t => ((t.name == 'url' || t.name == 'link') && t.props.url && !t.props.silent)) + .map(t => t.props.url)); } else { return null; } @@ -141,7 +142,7 @@ export default (opts: Opts = {}) => ({ this.$root.api('notes/favorites/create', { noteId: this.appearNote.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); diff --git a/src/client/app/common/scripts/should-mute-note.ts b/src/client/app/common/scripts/should-mute-note.ts index a849135763..4eab76421d 100644 --- a/src/client/app/common/scripts/should-mute-note.ts +++ b/src/client/app/common/scripts/should-mute-note.ts @@ -2,27 +2,8 @@ export default function(me, settings, note) { const isMyNote = note.userId == me.id; const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null; - if (settings.showMyRenotes === false) { - if (isMyNote && isPureRenote) { - return true; - } - } - - if (settings.showRenotedMyNotes === false) { - if (isPureRenote && (note.renote.userId == me.id)) { - return true; - } - } - - if (settings.showLocalRenotes === false) { - if (isPureRenote && (note.renote.user.host == null)) { - return true; - } - } - - if (!isMyNote && note.text && settings.mutedWords.some(q => !q.some(word => !note.text.includes(word)))) { - return true; - } - - return false; + return settings.showMyRenotes === false && isMyNote && isPureRenote || + settings.showRenotedMyNotes === false && isPureRenote && note.renote.userId == me.id || + settings.showLocalRenotes === false && isPureRenote && note.renote.user.host == null || + !isMyNote && note.text && settings.mutedWords.some(q => q.length > 0 && !q.some(word => !note.text.includes(word))); } diff --git a/src/client/app/common/scripts/stream.ts b/src/client/app/common/scripts/stream.ts index 345b112b15..23f839ae85 100644 --- a/src/client/app/common/scripts/stream.ts +++ b/src/client/app/common/scripts/stream.ts @@ -75,12 +75,10 @@ export default class Stream extends EventEmitter { // チャンネル再接続 if (isReconnect) { - this.sharedConnectionPools.forEach(p => { + for (const p of this.sharedConnectionPools) p.connect(); - }); - this.nonSharedConnections.forEach(c => { + for (const c of this.nonSharedConnections) c.connect(); - }); } } @@ -113,9 +111,9 @@ export default class Stream extends EventEmitter { connections = [this.nonSharedConnections.find(c => c.id === id)]; } - connections.filter(c => c != null).forEach(c => { + for (const c of connections.filter(c => c != null)) { c.emit(body.type, body.body); - }); + } } else { this.emit(type, body); } diff --git a/src/client/app/common/views/components/api-settings.vue b/src/client/app/common/views/components/api-settings.vue index 062218b3f4..e96eb28d93 100644 --- a/src/client/app/common/views/components/api-settings.vue +++ b/src/client/app/common/views/components/api-settings.vue @@ -50,10 +50,13 @@ export default Vue.extend({ methods: { regenerateToken() { - this.$input({ + this.$root.dialog({ title: this.$t('enter-password'), - type: 'password' - }).then(password => { + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; this.$root.api('i/regenerate_token', { password: password }); diff --git a/src/client/app/common/views/components/autocomplete.vue b/src/client/app/common/views/components/autocomplete.vue index 01461c7280..e33e4ae8c5 100644 --- a/src/client/app/common/views/components/autocomplete.vue +++ b/src/client/app/common/views/components/autocomplete.vue @@ -3,7 +3,9 @@ <ol class="users" ref="suggests" v-if="users.length > 0"> <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1"> <img class="avatar" :src="user.avatarUrl" alt=""/> - <span class="name">{{ user | userName }}</span> + <span class="name"> + <mk-user-name :user="user"/> + </span> <span class="username">@{{ user | acct }}</span> </li> </ol> @@ -42,8 +44,9 @@ const lib = Object.entries(emojilib.lib).filter((x: any) => { }); const char2file = (char: string) => { - let codes = [...char].map(x => x.codePointAt(0).toString(16)); + let codes = Array.from(char).map(x => x.codePointAt(0).toString(16)); if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f'); + codes = codes.filter(x => x && x.length); return codes.join('-'); }; @@ -54,18 +57,18 @@ const emjdb: EmojiDef[] = lib.map((x: any) => ({ url: `https://twemoji.maxcdn.com/2/svg/${char2file(x[1].char)}.svg` })); -lib.forEach((x: any) => { +for (const x of lib as any) { if (x[1].keywords) { - x[1].keywords.forEach(k => { + for (const k of x[1].keywords) { emjdb.push({ emoji: x[1].char, name: k, aliasOf: x[0], url: `https://twemoji.maxcdn.com/2/svg/${char2file(x[1].char)}.svg` }); - }); + } } -}); +} emjdb.sort((a, b) => a.name.length - b.name.length); @@ -117,7 +120,7 @@ export default Vue.extend({ const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; const emojiDefinitions: EmojiDef[] = []; - customEmojis.forEach(x => { + for (const x of customEmojis) { emojiDefinitions.push({ name: x.name, emoji: `:${x.name}:`, @@ -126,7 +129,7 @@ export default Vue.extend({ }); if (x.aliases) { - x.aliases.forEach(alias => { + for (const alias of x.aliases) { emojiDefinitions.push({ name: alias, aliasOf: x.name, @@ -134,9 +137,9 @@ export default Vue.extend({ url: x.url, isCustomEmoji: true }); - }); + } } - }); + } emojiDefinitions.sort((a, b) => a.name.length - b.name.length); @@ -145,9 +148,9 @@ export default Vue.extend({ this.textarea.addEventListener('keydown', this.onKeydown); - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.addEventListener('mousedown', this.onMousedown); - }); + } this.$nextTick(() => { this.exec(); @@ -163,18 +166,18 @@ export default Vue.extend({ beforeDestroy() { this.textarea.removeEventListener('keydown', this.onKeydown); - Array.from(document.querySelectorAll('body *')).forEach(el => { + for (const el of Array.from(document.querySelectorAll('body *'))) { el.removeEventListener('mousedown', this.onMousedown); - }); + } }, methods: { exec() { this.select = -1; if (this.$refs.suggests) { - Array.from(this.items).forEach(el => { + for (const el of Array.from(this.items)) { el.removeAttribute('data-selected'); - }); + } } if (this.type == 'user') { @@ -187,7 +190,8 @@ export default Vue.extend({ } else { this.$root.api('users/search', { query: this.q, - limit: 30 + limit: 10, + detail: false }).then(users => { this.users = users; this.fetching = false; @@ -312,9 +316,9 @@ export default Vue.extend({ }, applySelect() { - Array.from(this.items).forEach(el => { + for (const el of Array.from(this.items)) { el.removeAttribute('data-selected'); - }); + } this.items[this.select].setAttribute('data-selected', 'true'); (this.items[this.select] as any).focus(); diff --git a/src/client/app/common/views/components/cw-button.vue b/src/client/app/common/views/components/cw-button.vue index bda39f2d48..034848a116 100644 --- a/src/client/app/common/views/components/cw-button.vue +++ b/src/client/app/common/views/components/cw-button.vue @@ -1,21 +1,36 @@ <template> -<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle">{{ value ? this.$t('hide') : this.$t('show') }}</button> +<button class="nrvgflfuaxwgkxoynpnumyookecqrrvh" @click="toggle"> + <b>{{ value ? this.$t('hide') : this.$t('show') }}</b> + <span v-if="!value"> + <span v-if="note.text">{{ this.$t('chars', { count: length(note.text) }) | number }}</span> + <span v-if="note.text && note.files && note.files.length > 0"> / </span> + <span v-if="note.files && note.files.length > 0">{{ this.$t('files', { count: note.files.length }) }}</span> + </span> +</button> </template> <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; +import { length } from 'stringz'; export default Vue.extend({ i18n: i18n('common/views/components/cw-button.vue'), + props: { value: { type: Boolean, required: true + }, + note: { + type: Object, + required: true } }, methods: { + length, + toggle() { this.$emit('input', !this.value); } @@ -37,4 +52,12 @@ export default Vue.extend({ &:hover background var(--cwButtonHoverBg) + > span + margin-left 4px + + &:before + content '(' + &:after + content ')' + </style> diff --git a/src/client/app/common/views/components/alert.vue b/src/client/app/common/views/components/dialog.vue index 27d876c87a..5cc885881b 100644 --- a/src/client/app/common/views/components/alert.vue +++ b/src/client/app/common/views/components/dialog.vue @@ -2,12 +2,17 @@ <div class="felqjxyj" :class="{ splash }"> <div class="bg" ref="bg" @click="onBgClick"></div> <div class="main" ref="main"> - <div class="icon" :class="type"><fa :icon="icon"/></div> + <div class="icon" v-if="!input && !select && !user" :class="type"><fa :icon="icon"/></div> <header v-if="title" v-html="title"></header> <div class="body" v-if="text" v-html="text"></div> - <ui-horizon-group no-grow class="buttons" v-if="!splash"> - <ui-button @click="ok" primary autofocus>OK</ui-button> - <ui-button @click="cancel" v-if="showCancelButton">Cancel</ui-button> + <ui-input v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></ui-input> + <ui-input v-if="user" v-model="userInputValue" autofocus @keydown="onInputKeydown"><span slot="prefix">@</span></ui-input> + <ui-select v-if="select" v-model="selectedValue"> + <option v-for="item in select.items" :value="item.value">{{ item.text }}</option> + </ui-select> + <ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash"> + <ui-button @click="ok" primary :autofocus="!input && !select && !user">OK</ui-button> + <ui-button @click="cancel" v-if="showCancelButton || input || select || user">Cancel</ui-button> </ui-horizon-group> </div> </div> @@ -17,6 +22,7 @@ import Vue from 'vue'; import * as anime from 'animejs'; import { faTimesCircle, faQuestionCircle } from '@fortawesome/free-regular-svg-icons'; +import parseAcct from "../../../../../misc/acct/parse"; export default Vue.extend({ props: { @@ -33,6 +39,15 @@ export default Vue.extend({ type: String, required: false }, + input: { + required: false + }, + select: { + required: false + }, + user: { + required: false + }, showCancelButton: { type: Boolean, default: false @@ -43,6 +58,14 @@ export default Vue.extend({ } }, + data() { + return { + inputValue: this.input && this.input.default ? this.input.default : null, + userInputValue: null, + selectedValue: null + }; + }, + computed: { icon(): any { switch (this.type) { @@ -82,9 +105,21 @@ export default Vue.extend({ }, methods: { - ok() { - this.$emit('ok'); - this.close(); + async ok() { + if (this.user) { + const user = await this.$root.api('users/show', parseAcct(this.userInputValue)); + if (user) { + this.$emit('ok', user); + this.close(); + } + } else { + const result = + this.input ? this.inputValue : + this.select ? this.selectedValue : + true; + this.$emit('ok', result); + this.close(); + } }, cancel() { @@ -114,6 +149,14 @@ export default Vue.extend({ onBgClick() { this.cancel(); + }, + + onInputKeydown(e) { + if (e.which == 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } } } }); @@ -180,8 +223,11 @@ export default Vue.extend({ display block margin 0 auto + & + header + margin-top 16px + > header - margin 16px 0 8px 0 + margin 0 0 8px 0 font-weight bold font-size 20px diff --git a/src/client/app/common/views/components/discord-setting.vue b/src/client/app/common/views/components/discord-setting.vue deleted file mode 100644 index 113df9b0ae..0000000000 --- a/src/client/app/common/views/components/discord-setting.vue +++ /dev/null @@ -1,64 +0,0 @@ -<template> -<div class="mk-discord-setting"> - <p>{{ $t('description') }}</p> - <p class="account" v-if="$store.state.i.discord" :title="`Discord ID: ${$store.state.i.discord.id}`">{{ $t('connected-to') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p> - <p> - <a :href="`${apiUrl}/connect/discord`" target="_blank" @click.prevent="connect">{{ $store.state.i.discord ? this.$t('reconnect') : this.$t('connect') }}</a> - <span v-if="$store.state.i.discord"> or </span> - <a :href="`${apiUrl}/disconnect/discord`" target="_blank" v-if="$store.state.i.discord" @click.prevent="disconnect">{{ $t('disconnect') }}</a> - </p> - <p class="id" v-if="$store.state.i.discord">Discord ID: {{ $store.state.i.discord.id }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/discord-setting.vue'), - data() { - return { - form: null, - apiUrl - }; - }, - mounted() { - this.$watch('$store.state.i', () => { - if (this.$store.state.i.discord && this.form) - this.form.close(); - }, { - deep: true - }); - }, - methods: { - connect() { - this.form = window.open(apiUrl + '/connect/discord', - 'discord_connect_window', - 'height=570, width=520'); - }, - - disconnect() { - window.open(apiUrl + '/disconnect/discord', - 'discord_disconnect_window', - 'height=570, width=520'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-discord-setting - .account - border solid 1px #e1e8ed - border-radius 4px - padding 16px - - a - font-weight bold - color inherit - - .id - color #8899a6 -</style> diff --git a/src/client/app/common/views/components/emoji-picker.vue b/src/client/app/common/views/components/emoji-picker.vue index 8181047167..f9164ad524 100644 --- a/src/client/app/common/views/components/emoji-picker.vue +++ b/src/client/app/common/views/components/emoji-picker.vue @@ -114,11 +114,11 @@ export default Vue.extend({ }, onScroll(e) { - const section = this.categories.forEach(x => { + for (const x of this.categories) { const top = e.target.scrollTop; const el = this.$refs[x.ref][0]; x.isActive = el.offsetTop <= top && el.offsetTop + el.offsetHeight > top; - }); + } }, chosen(emoji) { diff --git a/src/client/app/common/views/components/emoji.vue b/src/client/app/common/views/components/emoji.vue index a8fef35b8a..29b09947e4 100644 --- a/src/client/app/common/views/components/emoji.vue +++ b/src/client/app/common/views/components/emoji.vue @@ -1,5 +1,5 @@ <template> -<img v-if="customEmoji" class="fvgwvorwhxigeolkkrcderjzcawqrscl custom" :src="url" :alt="alt" :title="alt"/> +<img v-if="customEmoji" class="fvgwvorwhxigeolkkrcderjzcawqrscl custom" :class="{ normal: normal }" :src="url" :alt="alt" :title="alt"/> <img v-else-if="char && !useOsDefaultEmojis" class="fvgwvorwhxigeolkkrcderjzcawqrscl" :src="url" :alt="alt" :title="alt"/> <span v-else-if="char && useOsDefaultEmojis">{{ char }}</span> <span v-else>:{{ name }}:</span> @@ -20,6 +20,11 @@ export default Vue.extend({ type: String, required: false }, + normal: { + type: Boolean, + required: false, + default: false + }, customEmojis: { required: false, default: () => [] @@ -61,8 +66,9 @@ export default Vue.extend({ } if (this.char) { - let codes = [...this.char].map(x => x.codePointAt(0).toString(16)); + 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 = `https://twemoji.maxcdn.com/2/svg/${codes.join('-')}.svg`; } @@ -83,4 +89,11 @@ export default Vue.extend({ &:hover transform scale(1.2) + &.normal + height 1.25em + vertical-align -0.25em + + &:hover + transform none + </style> diff --git a/src/client/app/common/views/components/games/reversi/reversi.game.vue b/src/client/app/common/views/components/games/reversi/reversi.game.vue index 14c0c0891c..6d13b34c32 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.game.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.game.vue @@ -1,7 +1,7 @@ <template> <div class="xqnhankfuuilcwvhgsopeqncafzsquya"> <button class="go-index" v-if="selfNav" @click="goIndex"><fa icon="arrow-left"/></button> - <header><b><router-link :to="blackUser | userPage">{{ blackUser | userName }}</router-link></b>({{ $t('@.reversi.black') }}) vs <b><router-link :to="whiteUser | userPage">{{ whiteUser | userName }}</router-link></b>({{ $t('@.reversi.white') }})</header> + <header><b><router-link :to="blackUser | userPage"><mk-user-name :user="blackUser"/></router-link></b>({{ $t('@.reversi.black') }}) vs <b><router-link :to="whiteUser | userPage"><mk-user-name :user="whiteUser"/></router-link></b>({{ $t('@.reversi.white') }})</header> <div style="overflow: hidden; line-height: 28px;"> <p class="turn" v-if="!iAmPlayer && !game.isEnded">{{ $t('@.reversi.turn-of', { name: $options.filters.userName(turnUser) }) }}<mk-ellipsis/></p> @@ -10,7 +10,7 @@ <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn" v-animate-css="{ classes: 'tada', iteration: 'infinite' }">{{ $t('@.reversi.my-turn') }}</p> <p class="result" v-if="game.isEnded && logPos == logs.length"> <template v-if="game.winner"> - <span>{{ $t('@.reversi.won', { name: $options.filters.userName(game.winner) }) }}</span> + <misskey-flavored-markdown :text="$t('@.reversi.won', { name: $options.filters.userName(game.winner) })" :shouldBreak="false" :plainText="true" :custom-emojis="game.winner.emojis"/> <span v-if="game.surrendered != null"> ({{ $t('surrendered') }})</span> </template> <template v-else>{{ $t('@.reversi.drawn') }}</template> @@ -30,8 +30,14 @@ :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}`"> - <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black" :class="{ contrast: $store.state.settings.games.reversi.useContrastStones }"> - <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white" :class="{ contrast: $store.state.settings.games.reversi.useContrastStones }"> + <template v-if="!$store.state.settings.games.reversi.useWhiteBlackStones"> + <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black" :class="{ contrast: $store.state.settings.games.reversi.useContrastStones }"> + <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white" :class="{ contrast: $store.state.settings.games.reversi.useContrastStones }"> + </template> + <template v-if="$store.state.settings.games.reversi.useWhiteBlackStones"> + <fa v-if="stone === true" :icon="fasCircle"/> + <fa v-if="stone === false" :icon="farCircle"/> + </template> </div> </div> <div class="labels-y" v-if="this.$store.state.settings.games.reversi.showBoardLabels"> @@ -50,15 +56,13 @@ </div> <div class="player" v-if="game.isEnded"> - <div> - <button @click="logPos = 0" :disabled="logPos == 0"><fa icon="angle-double-left"/></button> - <button @click="logPos--" :disabled="logPos == 0"><fa icon="angle-left"/></button> - </div> <span>{{ logPos }} / {{ logs.length }}</span> - <div> - <button @click="logPos++" :disabled="logPos == logs.length"><fa icon="angle-right"/></button> - <button @click="logPos = logs.length" :disabled="logPos == logs.length"><fa icon="angle-double-right"/></button> - </div> + <ui-horizon-group> + <ui-button @click="logPos = 0" :disabled="logPos == 0"><fa :icon="faAngleDoubleLeft"/></ui-button> + <ui-button @click="logPos--" :disabled="logPos == 0"><fa :icon="faAngleLeft"/></ui-button> + <ui-button @click="logPos++" :disabled="logPos == logs.length"><fa :icon="faAngleRight"/></ui-button> + <ui-button @click="logPos = logs.length" :disabled="logPos == logs.length"><fa :icon="faAngleDoubleRight"/></ui-button> + </ui-horizon-group> </div> <div class="info"> @@ -75,6 +79,9 @@ import i18n from '../../../../../i18n'; import * as CRC32 from 'crc-32'; import Reversi, { Color } from '../../../../../../../games/reversi/core'; import { url } from '../../../../../config'; +import { faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('common/views/components/games/reversi/reversi.game.vue'), @@ -99,7 +106,8 @@ export default Vue.extend({ o: null as Reversi, logs: [], logPos: 0, - pollingClock: null + pollingClock: null, + faAngleDoubleLeft, faAngleLeft, faAngleRight, faAngleDoubleRight, fasCircle, farCircle }; }, @@ -177,9 +185,9 @@ export default Vue.extend({ loopedBoard: this.game.settings.loopedBoard }); - this.game.logs.forEach(log => { + for (const log of this.game.logs) { this.o.put(log.color, log.pos); - }); + } this.logs = this.game.logs; this.logPos = this.logs.length; @@ -279,9 +287,9 @@ export default Vue.extend({ loopedBoard: this.game.settings.loopedBoard }); - this.game.logs.forEach(log => { + 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; @@ -412,6 +420,11 @@ export default Vue.extend({ &.none border-color transparent !important + > svg + display block + width 100% + height 100% + > img display block width 100% @@ -449,7 +462,9 @@ export default Vue.extend({ padding-bottom 16px > .player - padding-bottom 32px + padding 0 16px 32px 16px + margin 0 auto + max-width 500px > span display inline-block diff --git a/src/client/app/common/views/components/games/reversi/reversi.index.vue b/src/client/app/common/views/components/games/reversi/reversi.index.vue index b82a60a360..834702fda9 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.index.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.index.vue @@ -19,7 +19,7 @@ <h2>{{ $t('invitations') }}</h2> <div class="invitation" v-for="i in invitations" tabindex="-1" @click="accept(i)"> <mk-avatar class="avatar" :user="i.parent"/> - <span class="name"><b>{{ i.parent | userName }}</b></span> + <span class="name"><b><mk-user-name :user="i.parent"/></b></span> <span class="username">@{{ i.parent.username }}</span> <mk-time :time="i.createdAt"/> </div> @@ -29,7 +29,7 @@ <a class="game" v-for="g in myGames" tabindex="-1" @click.prevent="go(g)" :href="`/reversi/${g.id}`"> <mk-avatar class="avatar" :user="g.user1"/> <mk-avatar class="avatar" :user="g.user2"/> - <span><b>{{ g.user1 | userName }}</b> vs <b>{{ g.user2 | userName }}</b></span> + <span><b><mk-user-name :user="g.user1"/></b> vs <b><mk-user-name :user="g.user2"/></b></span> <span class="state">{{ g.isEnded ? $t('game-state.ended') : $t('game-state.playing') }}</span> <mk-time :time="g.createdAt" /> </a> @@ -39,7 +39,7 @@ <a class="game" v-for="g in games" tabindex="-1" @click.prevent="go(g)" :href="`/reversi/${g.id}`"> <mk-avatar class="avatar" :user="g.user1"/> <mk-avatar class="avatar" :user="g.user2"/> - <span><b>{{ g.user1 | userName }}</b> vs <b>{{ g.user2 | userName }}</b></span> + <span><b><mk-user-name :user="g.user1"/></b> vs <b><mk-user-name :user="g.user2"/></b></span> <span class="state">{{ g.isEnded ? $t('game-state.ended') : $t('game-state.playing') }}</span> <mk-time :time="g.createdAt" /> </a> @@ -99,23 +99,22 @@ export default Vue.extend({ this.$emit('go', game); }, - match() { - this.$input({ - title: this.$t('enter-username') - }).then(username => { - this.$root.api('users/show', { - username - }).then(user => { - this.$root.api('games/reversi/match', { - userId: user.id - }).then(res => { - if (res == null) { - this.$emit('matching', user); - } else { - this.$emit('go', res); - } - }); - }); + async match() { + const { result: user } = await this.$root.dialog({ + title: this.$t('enter-username'), + user: { + local: true + } + }); + if (user == null) return; + this.$root.api('games/reversi/match', { + userId: user.id + }).then(res => { + if (res == null) { + this.$emit('matching', user); + } else { + this.$emit('go', res); + } }); }, diff --git a/src/client/app/common/views/components/games/reversi/reversi.room.vue b/src/client/app/common/views/components/games/reversi/reversi.room.vue index 92cdc6c083..fdbdf9b9e5 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.room.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.room.vue @@ -1,6 +1,6 @@ <template> <div class="urbixznjwwuukfsckrwzwsqzsxornqij"> - <header><b>{{ game.user1 | userName }}</b> vs <b>{{ game.user2 | userName }}</b></header> + <header><b><mk-user-name :user="game.user1"/></b> vs <b><mk-user-name :user="game.user2"/></b></header> <div> <p>{{ $t('settings-of-the-game') }}</p> @@ -22,8 +22,8 @@ <div v-for="(x, i) in game.settings.map.join('')" :data-none="x == ' '" @click="onPixelClick(i, x)"> - <template v-if="x == 'b'"><template v-if="$store.state.device.darkmode"><fa :icon="['far', 'circle']"/></template><template v-else><fa icon="circle"/></template></template> - <template v-if="x == 'w'"><template v-if="$store.state.device.darkmode"><fa :icon="['far', 'circle']"/></template><template v-else><fa icon="circle"/></template></template> + <fa v-if="x == 'b'" :icon="fasCircle"/> + <fa v-if="x == 'w'" :icon="farCircle"/> </div> </div> </div> @@ -36,8 +36,8 @@ <div> <form-radio v-model="game.settings.bw" value="random" @change="updateSettings">{{ $t('random') }}</form-radio> - <form-radio v-model="game.settings.bw" :value="1" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b>{{ game.user1 | userName }}</b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> - <form-radio v-model="game.settings.bw" :value="2" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b>{{ game.user2 | userName }}</b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> + <form-radio v-model="game.settings.bw" :value="1" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b><mk-user-name :user="game.user1"/></b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> + <form-radio v-model="game.settings.bw" :value="2" @change="updateSettings">{{ this.$t('black-is').split('{}')[0] }}<b><mk-user-name :user="game.user2"/></b>{{ this.$t('black-is').split('{}')[1] }}</form-radio> </div> </div> @@ -117,6 +117,8 @@ import Vue from 'vue'; import i18n from '../../../../../i18n'; import * as maps from '../../../../../../../games/reversi/maps'; +import { faCircle as fasCircle } from '@fortawesome/free-solid-svg-icons'; +import { faCircle as farCircle } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('common/views/components/games/reversi/reversi.room.vue'), @@ -129,7 +131,8 @@ export default Vue.extend({ mapName: maps.eighteight.name, maps: maps, form: null, - messages: [] + messages: [], + fasCircle, farCircle }; }, diff --git a/src/client/app/common/views/components/games/reversi/reversi.vue b/src/client/app/common/views/components/games/reversi/reversi.vue index 8c555a6c4f..b6803cd7f7 100644 --- a/src/client/app/common/views/components/games/reversi/reversi.vue +++ b/src/client/app/common/views/components/games/reversi/reversi.vue @@ -4,7 +4,7 @@ <x-gameroom :game="game" :self-nav="selfNav" @go-index="goIndex"/> </div> <div class="matching" v-else-if="matching"> - <h1>{{ this.$t('matching.waiting-for').split('{}')[0] }}<b>{{ matching | userName }}</b>{{ this.$t('matching.waiting-for').split('{}')[1] }}<mk-ellipsis/></h1> + <h1>{{ this.$t('matching.waiting-for').split('{}')[0] }}<b><mk-user-name :user="matching"/></b>{{ this.$t('matching.waiting-for').split('{}')[1] }}<mk-ellipsis/></h1> <div class="cancel"> <form-button round @click="cancel">{{ $t('matching.cancel') }}</form-button> </div> diff --git a/src/client/app/common/views/components/github-setting.vue b/src/client/app/common/views/components/github-setting.vue deleted file mode 100644 index 93d7f406f8..0000000000 --- a/src/client/app/common/views/components/github-setting.vue +++ /dev/null @@ -1,64 +0,0 @@ -<template> -<div class="mk-github-setting"> - <p>{{ $t('description') }}</p> - <p class="account" v-if="$store.state.i.github" :title="`GitHub ID: ${$store.state.i.github.id}`">{{ $t('connected-to') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" target="_blank">@{{ $store.state.i.github.login }}</a></p> - <p> - <a :href="`${apiUrl}/connect/github`" target="_blank" @click.prevent="connect">{{ $store.state.i.github ? this.$t('reconnect') : this.$t('connect') }}</a> - <span v-if="$store.state.i.github"> or </span> - <a :href="`${apiUrl}/disconnect/github`" target="_blank" v-if="$store.state.i.github" @click.prevent="disconnect">{{ $t('disconnect') }}</a> - </p> - <p class="id" v-if="$store.state.i.github">GitHub ID: {{ $store.state.i.github.id }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/github-setting.vue'), - data() { - return { - form: null, - apiUrl - }; - }, - mounted() { - this.$watch('$store.state.i', () => { - if (this.$store.state.i.github && this.form) - this.form.close(); - }, { - deep: true - }); - }, - methods: { - connect() { - this.form = window.open(apiUrl + '/connect/github', - 'github_connect_window', - 'height=570, width=520'); - }, - - disconnect() { - window.open(apiUrl + '/disconnect/github', - 'github_disconnect_window', - 'height=570, width=520'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-github-setting - .account - border solid 1px #e1e8ed - border-radius 4px - padding 16px - - a - font-weight bold - color inherit - - .id - color #8899a6 -</style> diff --git a/src/client/app/common/views/components/google.vue b/src/client/app/common/views/components/google.vue index 1d852cf25a..dab2e6824a 100644 --- a/src/client/app/common/views/components/google.vue +++ b/src/client/app/common/views/components/google.vue @@ -22,7 +22,10 @@ export default Vue.extend({ }, methods: { search() { - window.open(`https://www.google.com/?#q=${this.query}`, '_blank'); + const engine = this.$store.state.settings.webSearchEngine || + 'https://www.google.com/?#q={{query}}'; + const url = engine.replace('{{query}}', this.query) + window.open(url, '_blank'); } } }); diff --git a/src/client/app/common/views/components/image-viewer.vue b/src/client/app/common/views/components/image-viewer.vue index b86a110337..204355b182 100644 --- a/src/client/app/common/views/components/image-viewer.vue +++ b/src/client/app/common/views/components/image-viewer.vue @@ -65,5 +65,6 @@ export default Vue.extend({ max-height 100% margin auto cursor zoom-out + image-orientation from-image </style> diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts index ace9eaf44f..40d067666a 100644 --- a/src/client/app/common/views/components/index.ts +++ b/src/client/app/common/views/components/index.ts @@ -1,5 +1,6 @@ import Vue from 'vue'; +import userName from './user-name.vue'; import followButton from './follow-button.vue'; import error from './error.vue'; import noteSkeleton from './note-skeleton.vue'; @@ -10,13 +11,14 @@ import trends from './trends.vue'; import analogClock from './analog-clock.vue'; import menu from './menu.vue'; import noteHeader from './note-header.vue'; +import renote from './renote.vue'; import signin from './signin.vue'; import signup from './signup.vue'; import forkit from './forkit.vue'; import acct from './acct.vue'; import avatar from './avatar.vue'; import nav from './nav.vue'; -import misskeyFlavoredMarkdown from './misskey-flavored-markdown'; +import misskeyFlavoredMarkdown from './misskey-flavored-markdown.vue'; import poll from './poll.vue'; import pollEditor from './poll-editor.vue'; import reactionIcon from './reaction-icon.vue'; @@ -43,6 +45,7 @@ import uiInfo from './ui/info.vue'; import formButton from './ui/form/button.vue'; import formRadio from './ui/form/radio.vue'; +Vue.component('mk-user-name', userName); Vue.component('mk-follow-button', followButton); Vue.component('mk-error', error); Vue.component('mk-note-skeleton', noteSkeleton); @@ -53,6 +56,7 @@ Vue.component('mk-trends', trends); Vue.component('mk-analog-clock', analogClock); Vue.component('mk-menu', menu); Vue.component('mk-note-header', noteHeader); +Vue.component('mk-renote', renote); Vue.component('mk-signin', signin); Vue.component('mk-signup', signup); Vue.component('mk-forkit', forkit); diff --git a/src/client/app/common/views/components/integration-settings.vue b/src/client/app/common/views/components/integration-settings.vue new file mode 100644 index 0000000000..4947d7305c --- /dev/null +++ b/src/client/app/common/views/components/integration-settings.vue @@ -0,0 +1,103 @@ +<template> +<ui-card> + <div slot="title"><fa icon="share-alt"/> {{ $t('title') }}</div> + + <section> + <header><fa :icon="['fab', 'twitter']"/> Twitter</header> + <p v-if="$store.state.i.twitter">{{ $t('connected-to') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> + <ui-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnect') }}</ui-button> + <ui-button v-else @click="connectTwitter">{{ $t('connect') }}</ui-button> + </section> + + <section> + <header><fa :icon="['fab', 'discord']"/> Discord</header> + <p v-if="$store.state.i.discord">{{ $t('connected-to') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p> + <ui-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnect') }}</ui-button> + <ui-button v-else @click="connectDiscord">{{ $t('connect') }}</ui-button> + </section> + + <section> + <header><fa :icon="['fab', 'github']"/> GitHub</header> + <p v-if="$store.state.i.github">{{ $t('connected-to') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" target="_blank">@{{ $store.state.i.github.login }}</a></p> + <ui-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnect') }}</ui-button> + <ui-button v-else @click="connectGithub">{{ $t('connect') }}</ui-button> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { apiUrl } from '../../../config'; + +export default Vue.extend({ + i18n: i18n('common/views/components/integration-settings.vue'), + + data() { + return { + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + }; + }, + + mounted() { + document.cookie = `i=${this.$store.state.i.token}`; + this.$watch('$store.state.i', () => { + if (this.$store.state.i.twitter) { + if (this.twitterForm) this.twitterForm.close(); + } + if (this.$store.state.i.discord) { + if (this.discordForm) this.discordForm.close(); + } + if (this.$store.state.i.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> + +<style lang="stylus" scoped> +</style> diff --git a/src/client/app/common/views/components/language-settings.vue b/src/client/app/common/views/components/language-settings.vue new file mode 100644 index 0000000000..aa3f290511 --- /dev/null +++ b/src/client/app/common/views/components/language-settings.vue @@ -0,0 +1,54 @@ +<template> +<ui-card> + <div slot="title"><fa icon="language"/> {{ $t('title') }}</div> + + <section class="fit-top"> + <ui-select v-model="lang" :placeholder="$t('pick-language')"> + <optgroup :label="$t('recommended')"> + <option value="">{{ $t('auto') }}</option> + </optgroup> + + <optgroup :label="$t('specify-language')"> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </optgroup> + </ui-select> + <ui-info>Current: <i>{{ currentLanguage }}</i></ui-info> + <ui-info warn>{{ $t('info') }}</ui-info> + </section> +</ui-card> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; +import { langs } from '../../../config'; + +export default Vue.extend({ + i18n: i18n('common/views/components/language-settings.vue'), + + data() { + return { + langs, + currentLanguage: 'Unknown', + }; + }, + + computed: { + lang: { + get() { return this.$store.state.device.lang; }, + set(value) { this.$store.commit('device/set', { key: 'lang', value }); } + }, + }, + + created() { + try { + const locale = JSON.parse(localStorage.getItem('locale') || "{}"); + const localeKey = localStorage.getItem('localeKey'); + this.currentLanguage = `${locale.meta.lang} (${localeKey})`; + } catch { } + }, + + methods: { + } +}); +</script> diff --git a/src/client/app/common/views/components/menu.vue b/src/client/app/common/views/components/menu.vue index e085bf4bb9..d601c74e7d 100644 --- a/src/client/app/common/views/components/menu.vue +++ b/src/client/app/common/views/components/menu.vue @@ -1,5 +1,5 @@ <template> -<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv"> +<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv" :class="{ big: $root.isMobile }"> <div class="backdrop" ref="backdrop" @click="close"></div> <div class="popover" :class="{ hukidasi }" ref="popover"> <template v-for="item, i in items"> @@ -125,6 +125,11 @@ export default Vue.extend({ position initial + &.big + > .popover + > button + font-size 15px + > .backdrop position fixed top 0 @@ -180,6 +185,7 @@ export default Vue.extend({ padding 8px 16px width 100% color var(--popupFg) + white-space nowrap &:hover color var(--primaryForeground) diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue index 966bd54170..fa77fa7af1 100644 --- a/src/client/app/common/views/components/messaging-room.message.vue +++ b/src/client/app/common/views/components/messaging-room.message.vue @@ -10,7 +10,8 @@ <misskey-flavored-markdown class="text" v-if="message.text" ref="text" :text="message.text" :i="$store.state.i"/> <div class="file" v-if="message.file"> <a :href="message.file.url" target="_blank" :title="message.file.name"> - <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/> + <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name" + :style="{ backgroundColor: message.file.properties.avgColor && message.file.properties.avgColor.length == 3 ? `rgb(${message.file.properties.avgColor.join(',')})` : 'transparent' }"/> <p v-else>{{ message.file.name }}</p> </a> </div> @@ -51,8 +52,8 @@ export default Vue.extend({ if (this.message.text) { const ast = parse(this.message.text); return unique(ast - .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) - .map(t => t.url)); + .filter(t => ((t.name == 'url' || t.name == 'link') && t.props.url && !t.silent)) + .map(t => t.props.url)); } else { return null; } @@ -150,7 +151,6 @@ export default Vue.extend({ > a display block max-width 100% - max-height 512px border-radius 16px overflow hidden text-decoration none @@ -165,7 +165,8 @@ export default Vue.extend({ display block margin 0 width 100% - height 100% + max-height 512px + object-fit contain > p padding 30px diff --git a/src/client/app/common/views/components/messaging-room.vue b/src/client/app/common/views/components/messaging-room.vue index b6132ceeb0..29aacd3bae 100644 --- a/src/client/app/common/views/components/messaging-room.vue +++ b/src/client/app/common/views/components/messaging-room.vue @@ -196,12 +196,12 @@ export default Vue.extend({ onRead(ids) { if (!Array.isArray(ids)) ids = [ids]; - ids.forEach(id => { + for (const id of ids) { if (this.messages.some(x => x.id == id)) { const exist = this.messages.map(x => x.id).indexOf(id); this.messages[exist].isRead = true; } - }); + } }, isBottom() { @@ -248,13 +248,13 @@ export default Vue.extend({ onVisibilitychange() { if (document.hidden) return; - this.messages.forEach(message => { + for (const message of this.messages) { if (message.userId !== this.$store.state.i.id && !message.isRead) { this.connection.send('read', { id: message.id }); } - }); + } } } }); diff --git a/src/client/app/common/views/components/messaging.vue b/src/client/app/common/views/components/messaging.vue index 5b3fc790d4..9683ca0ca3 100644 --- a/src/client/app/common/views/components/messaging.vue +++ b/src/client/app/common/views/components/messaging.vue @@ -14,7 +14,7 @@ tabindex="-1" > <mk-avatar class="avatar" :user="user"/> - <span class="name">{{ user | userName }}</span> + <span class="name"><mk-user-name :user="user"/></span> <span class="username">@{{ user | acct }}</span> </li> </ol> @@ -33,7 +33,7 @@ <div> <mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/> <header> - <span class="name">{{ isMe(message) ? message.recipient : message.user | userName }}</span> + <span class="name"><mk-user-name :user="isMe(message) ? message.recipient : message.user"/></span> <span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span> <mk-time :time="message.createdAt"/> </header> @@ -103,10 +103,10 @@ export default Vue.extend({ this.messages.unshift(message); }, onRead(ids) { - ids.forEach(id => { + for (const id of ids) { const found = this.messages.find(m => m.id == id); if (found) found.isRead = true; - }); + } }, search() { if (this.q == '') { @@ -115,9 +115,11 @@ export default Vue.extend({ } this.$root.api('users/search', { query: this.q, - max: 5 + localOnly: true, + limit: 10, + detail: false }).then(users => { - this.result = users; + this.result = users.filter(user => user.id != this.$store.state.i.id); }); }, navigate(user) { diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.ts b/src/client/app/common/views/components/mfm.ts index 1eb738813e..a6487aa4fb 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.ts +++ b/src/client/app/common/views/components/mfm.ts @@ -1,11 +1,24 @@ import Vue, { VNode } from 'vue'; import { length } from 'stringz'; +import { Node } from '../../../../../mfm/parser'; import parse from '../../../../../mfm/parse'; -import getAcct from '../../../../../misc/acct/render'; import MkUrl from './url.vue'; -import { concat } from '../../../../../prelude/array'; +import { concat, sum } from '../../../../../prelude/array'; import MkFormula from './formula.vue'; import MkGoogle from './google.vue'; +import { toUnicode } from 'punycode'; +import syntaxHighlight from '../../../../../mfm/syntax-highlight'; + +function getTextCount(tokens: Node[]): number { + const rootCount = sum(tokens.filter(x => x.name === 'text').map(x => length(x.props.text))); + const childrenCount = sum(tokens.filter(x => x.children).map(x => getTextCount(x.children))); + return rootCount + childrenCount; +} + +function getChildrenCount(tokens: Node[]): number { + const countTree = tokens.filter(x => x.children).map(x => getChildrenCount(x.children)); + return countTree.length + sum(countTree); +} export default Vue.component('misskey-flavored-markdown', { props: { @@ -21,6 +34,14 @@ export default Vue.component('misskey-flavored-markdown', { type: Boolean, default: true }, + plainText: { + type: Boolean, + default: false + }, + author: { + type: Object, + default: null + }, i: { type: Object, default: null @@ -31,23 +52,19 @@ export default Vue.component('misskey-flavored-markdown', { }, render(createElement) { - let ast: any[]; + if (this.text == null || this.text == '') return; - if (this.ast == null) { - // Parse text to ast - ast = parse(this.text); - } else { - ast = this.ast as any[]; - } + const ast = this.ast == null ? + parse(this.text, this.plainText) : // Parse text to ast + this.ast as Node[]; let bigCount = 0; let motionCount = 0; - // Parse ast to DOM - const els = concat(ast.map((token): VNode[] => { - switch (token.type) { + const genEl = (ast: Node[]) => concat(ast.map((token): VNode[] => { + switch (token.name) { case 'text': { - const text = token.content.replace(/(\r\n|\n|\r)/g, '\n'); + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); if (this.shouldBreak) { const x = text.split('\n') @@ -60,12 +77,24 @@ export default Vue.component('misskey-flavored-markdown', { } case 'bold': { - return [createElement('b', token.bold)]; + return [createElement('b', genEl(token.children))]; + } + + case 'strike': { + return [createElement('del', genEl(token.children))]; + } + + case 'italic': { + return (createElement as any)('i', { + attrs: { + style: 'font-style: oblique;' + }, + }, genEl(token.children)); } case 'big': { bigCount++; - const isLong = length(token.big) > 10; + const isLong = getTextCount(token.children) > 10 || getChildrenCount(token.children) > 5; const isMany = bigCount > 3; return (createElement as any)('strong', { attrs: { @@ -75,12 +104,24 @@ export default Vue.component('misskey-flavored-markdown', { name: 'animate-css', value: { classes: 'tada', iteration: 'infinite' } }] - }, token.big); + }, genEl(token.children)); + } + + case 'small': { + return [createElement('small', genEl(token.children))]; + } + + case 'center': { + return [createElement('div', { + attrs: { + style: 'text-align:center;' + } + }, genEl(token.children))]; } case 'motion': { motionCount++; - const isLong = length(token.motion) > 10; + const isLong = getTextCount(token.children) > 10 || getChildrenCount(token.children) > 5; const isMany = motionCount > 3; return (createElement as any)('span', { attrs: { @@ -90,13 +131,14 @@ export default Vue.component('misskey-flavored-markdown', { name: 'animate-css', value: { classes: 'rubberBand', iteration: 'infinite' } }] - }, token.motion); + }, genEl(token.children)); } case 'url': { return [createElement(MkUrl, { + key: Math.random(), props: { - url: token.content, + url: token.props.url, target: '_blank', style: 'color:var(--mfmLink);' } @@ -107,75 +149,75 @@ export default Vue.component('misskey-flavored-markdown', { return [createElement('a', { attrs: { class: 'link', - href: token.url, + href: token.props.url, target: '_blank', - title: token.url, + title: token.props.url, style: 'color:var(--mfmLink);' } - }, token.title)]; + }, genEl(token.children))]; } case 'mention': { + const host = token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host; + const canonical = host != null ? `@${token.props.username}@${toUnicode(host)}` : `@${token.props.username}`; return (createElement as any)('router-link', { + key: Math.random(), attrs: { - to: `/${token.canonical}`, - dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), + to: `/${canonical}`, + // TODO + //dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token), style: 'color:var(--mfmMention);' }, directives: [{ name: 'user-preview', - value: token.canonical + value: canonical }] - }, token.canonical); + }, canonical); } case 'hashtag': { return [createElement('router-link', { + key: Math.random(), attrs: { - to: `/tags/${encodeURIComponent(token.hashtag)}`, + to: `/tags/${encodeURIComponent(token.props.hashtag)}`, style: 'color:var(--mfmHashtag);' } - }, token.content)]; + }, `#${token.props.hashtag}`)]; } - case 'code': { + case 'blockCode': { return [createElement('pre', { class: 'code' }, [ createElement('code', { domProps: { - innerHTML: token.html + innerHTML: syntaxHighlight(token.props.code) } }) ])]; } - case 'inline-code': { + case 'inlineCode': { return [createElement('code', { domProps: { - innerHTML: token.html + innerHTML: syntaxHighlight(token.props.code) } })]; } case 'quote': { - const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n'); - if (this.shouldBreak) { - const x = text2.split('\n') - .map(t => [createElement('span', t), createElement('br')]); - x[x.length - 1].pop(); return [createElement('div', { attrs: { class: 'quote' } - }, x)]; + }, genEl(token.children))]; } else { return [createElement('span', { attrs: { class: 'quote' } - }, text2.replace(/\n/g, ' '))]; + }, genEl(token.children))]; } } @@ -184,18 +226,20 @@ export default Vue.component('misskey-flavored-markdown', { attrs: { class: 'title' } - }, token.title)]; + }, genEl(token.children))]; } case 'emoji': { const customEmojis = (this.$root.getMetaSync() || { emojis: [] }).emojis || []; return [createElement('mk-emoji', { + key: Math.random(), attrs: { - emoji: token.emoji, - name: token.name + emoji: token.props.emoji, + name: token.props.name }, props: { - customEmojis: this.customEmojis || customEmojis + customEmojis: this.customEmojis || customEmojis, + normal: this.plainText } })]; } @@ -203,8 +247,9 @@ export default Vue.component('misskey-flavored-markdown', { case 'math': { //const MkFormula = () => import('./formula.vue').then(m => m.default); return [createElement(MkFormula, { + key: Math.random(), props: { - formula: token.formula + formula: token.props.formula } })]; } @@ -212,22 +257,22 @@ export default Vue.component('misskey-flavored-markdown', { case 'search': { //const MkGoogle = () => import('./google.vue').then(m => m.default); return [createElement(MkGoogle, { + key: Math.random(), props: { - q: token.query + q: token.props.query } })]; } default: { - console.log('unknown ast type:', token.type); + console.log('unknown ast type:', token.name); return []; } } })); - // el.tag === 'br' のとき i !== 0 が保証されるため、短絡評価により els[i - 1] は配列外参照しない - const _els = els.filter((el, i) => !(el.tag === 'br' && ['div', 'pre'].includes(els[i - 1].tag))); - return createElement('span', _els); + // Parse ast to DOM + return createElement('span', genEl(ast)); } }); diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.vue b/src/client/app/common/views/components/misskey-flavored-markdown.vue new file mode 100644 index 0000000000..65d6464d18 --- /dev/null +++ b/src/client/app/common/views/components/misskey-flavored-markdown.vue @@ -0,0 +1,57 @@ +<template> +<mfm v-bind="$attrs" class="havbbuyv"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Mfm from './mfm'; + +export default Vue.extend({ + components: { + Mfm + } +}); +</script> + +<style lang="stylus" scoped> +.havbbuyv + >>> .title + display block + margin-bottom 4px + padding 4px + font-size 90% + text-align center + background var(--mfmTitleBg) + border-radius 4px + + >>> .code + margin 8px 0 + + >>> .quote + margin 8px + padding 6px 0 6px 12px + color var(--mfmQuote) + border-left solid 3px var(--mfmQuoteLine) + + >>> code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background rgba(0, 0, 0, 0.05) + border-radius 2px + + >>> pre > code + padding 16px + margin 0 + + >>> [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color var(--primaryForeground) + background var(--primary) + border-radius 4px + +</style> diff --git a/src/client/app/common/views/components/mute-and-block.vue b/src/client/app/common/views/components/mute-and-block.vue index fdeaa97eb4..97e992ace1 100644 --- a/src/client/app/common/views/components/mute-and-block.vue +++ b/src/client/app/common/views/components/mute-and-block.vue @@ -7,7 +7,7 @@ <ui-info v-if="!muteFetching && mute.length == 0">{{ $t('no-muted-users') }}</ui-info> <div class="users" v-if="mute.length != 0"> <div v-for="user in mute" :key="user.id"> - <p><b>{{ user | userName }}</b> @{{ user | acct }}</p> + <p><b><mk-user-name :user="user"/></b> @{{ user | acct }}</p> </div> </div> </section> @@ -17,7 +17,7 @@ <ui-info v-if="!blockFetching && block.length == 0">{{ $t('no-blocked-users') }}</ui-info> <div class="users" v-if="block.length != 0"> <div v-for="user in block" :key="user.id"> - <p><b>{{ user | userName }}</b> @{{ user | acct }}</p> + <p><b><mk-user-name :user="user"/></b> @{{ user | acct }}</p> </div> </div> </section> @@ -72,7 +72,7 @@ export default Vue.extend({ methods: { save() { - this._mutedWords = this.mutedWords.split('\n').map(line => line.split(' ')); + this._mutedWords = this.mutedWords.split('\n').map(line => line.split(' ').filter(x => x != '')); } } }); diff --git a/src/client/app/common/views/components/note-header.vue b/src/client/app/common/views/components/note-header.vue index 1e457d2d72..664cb308e7 100644 --- a/src/client/app/common/views/components/note-header.vue +++ b/src/client/app/common/views/components/note-header.vue @@ -1,7 +1,9 @@ <template> <header class="bvonvjxbwzaiskogyhbwgyxvcgserpmu"> <mk-avatar class="avatar" :user="note.user" v-if="$store.state.device.postStyle == 'smart'"/> - <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id"> + <mk-user-name :user="note.user"/> + </router-link> <span class="is-admin" v-if="note.user.isAdmin">admin</span> <span class="is-bot" v-if="note.user.isBot">bot</span> <span class="is-cat" v-if="note.user.isCat">cat</span> diff --git a/src/client/app/common/views/components/note-menu.vue b/src/client/app/common/views/components/note-menu.vue index 7d15b4ed7f..b8f34beb0c 100644 --- a/src/client/app/common/views/components/note-menu.vue +++ b/src/client/app/common/views/components/note-menu.vue @@ -78,7 +78,7 @@ export default Vue.extend({ this.$root.api('i/pin', { noteId: this.note.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); @@ -95,12 +95,12 @@ export default Vue.extend({ }, del() { - this.$root.alert({ + this.$root.dialog({ type: 'warning', text: this.$t('delete-confirm'), showCancelButton: true - }).then(res => { - if (!res) return; + }).then(({ canceled }) => { + if (canceled) return; this.$root.api('notes/delete', { noteId: this.note.id @@ -114,7 +114,7 @@ export default Vue.extend({ this.$root.api('notes/favorites/create', { noteId: this.note.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); @@ -126,7 +126,7 @@ export default Vue.extend({ this.$root.api('notes/favorites/delete', { noteId: this.note.id }).then(() => { - this.$root.alert({ + this.$root.dialog({ type: 'success', splash: true }); diff --git a/src/client/app/common/views/components/password-settings.vue b/src/client/app/common/views/components/password-settings.vue index 356f8b2fa4..eb511d6213 100644 --- a/src/client/app/common/views/components/password-settings.vue +++ b/src/client/app/common/views/components/password-settings.vue @@ -11,33 +11,50 @@ import i18n from '../../../i18n'; export default Vue.extend({ i18n: i18n('common/views/components/password-settings.vue'), methods: { - reset() { - this.$input({ + async reset() { + const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({ title: this.$t('enter-current-password'), - type: 'password' - }).then(currentPassword => { - this.$input({ - title: this.$t('enter-new-password'), + input: { type: 'password' - }).then(newPassword => { - this.$input({ - title: this.$t('enter-new-password-again'), - type: 'password' - }).then(newPassword2 => { - if (newPassword !== newPassword2) { - this.$root.alert({ - title: null, - text: this.$t('not-match') - }); - return; - } - this.$root.api('i/change_password', { - currentPasword: currentPassword, - newPassword: newPassword - }).then(() => { - this.$notify(this.$t('changed')); - }); - }); + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await this.$root.dialog({ + title: this.$t('enter-new-password'), + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({ + title: this.$t('enter-new-password-again'), + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + this.$root.dialog({ + title: null, + text: this.$t('not-match') + }); + return; + } + this.$root.api('i/change_password', { + currentPassword, + newPassword + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('changed') + }); + }).catch(() => { + this.$root.dialog({ + type: 'error', + text: this.$t('failed') }); }); } diff --git a/src/client/app/common/views/components/poll.vue b/src/client/app/common/views/components/poll.vue index 8a31ec83d7..8817d88cc5 100644 --- a/src/client/app/common/views/components/poll.vue +++ b/src/client/app/common/views/components/poll.vue @@ -55,12 +55,12 @@ export default Vue.extend({ noteId: this.note.id, choice: id }).then(() => { - this.poll.choices.forEach(c => { + for (const c of this.poll.choices) { if (c.id == id) { c.votes++; Vue.set(c, 'isVoted', true); } - }); + } this.showResult = true; }); } diff --git a/src/client/app/common/views/components/profile-editor.vue b/src/client/app/common/views/components/profile-editor.vue index 080b8d6fc3..33c53c7dc8 100644 --- a/src/client/app/common/views/components/profile-editor.vue +++ b/src/client/app/common/views/components/profile-editor.vue @@ -32,6 +32,12 @@ <span>{{ $t('description') }}</span> </ui-textarea> + <ui-select v-model="lang"> + <span slot="label">{{ $t('language') }}</span> + <span slot="icon"><fa icon="language"/></span> + <option v-for="lang in unique(Object.values(langmap).map(x => x.nativeName)).map(name => Object.keys(langmap).find(k => langmap[k].nativeName == name))" :value="lang" :key="lang">{{ langmap[lang].nativeName }}</option> + </ui-select> + <ui-input type="file" @change="onAvatarChange"> <span>{{ $t('avatar') }}</span> <span slot="icon"><fa icon="image"/></span> @@ -66,6 +72,19 @@ <ui-switch v-model="carefulBot" @change="save(false)">{{ $t('careful-bot') }}</ui-switch> </div> </section> + + <section v-if="enableEmail"> + <header>{{ $t('email') }}</header> + + <div> + <template v-if="$store.state.i.email != null"> + <ui-info v-if="$store.state.i.emailVerified">{{ $t('email-verified') }}</ui-info> + <ui-info v-else warn>{{ $t('email-not-verified') }}</ui-info> + </template> + <ui-input v-model="email" type="email"><span>{{ $t('email-address') }}</span></ui-input> + <ui-button @click="updateEmail()">{{ $t('save') }}</ui-button> + </div> + </section> </ui-card> </template> @@ -74,16 +93,24 @@ import Vue from 'vue'; import i18n from '../../../i18n'; import { apiUrl, host } from '../../../config'; import { toUnicode } from 'punycode'; +import langmap from 'langmap'; +import { unique } from '../../../../../prelude/array'; export default Vue.extend({ i18n: i18n('common/views/components/profile-editor.vue'), + data() { return { + unique, + langmap, host: toUnicode(host), + enableEmail: false, + email: null, name: null, username: null, location: null, description: null, + lang: null, birthday: null, avatarId: null, bannerId: null, @@ -113,10 +140,15 @@ export default Vue.extend({ }, created() { - this.name = this.$store.state.i.name || ''; + this.$root.getMeta().then(meta => { + this.enableEmail = meta.enableEmail; + }); + this.email = this.$store.state.i.email; + this.name = this.$store.state.i.name; this.username = this.$store.state.i.username; this.location = this.$store.state.i.profile.location; this.description = this.$store.state.i.description; + this.lang = this.$store.state.i.lang; this.birthday = this.$store.state.i.profile.birthday; this.avatarId = this.$store.state.i.avatarId; this.bannerId = this.$store.state.i.bannerId; @@ -178,9 +210,10 @@ export default Vue.extend({ name: this.name || null, location: this.location || null, description: this.description || null, + lang: this.lang, birthday: this.birthday || null, - avatarId: this.avatarId, - bannerId: this.bannerId, + avatarId: this.avatarId || undefined, + bannerId: this.bannerId || undefined, isCat: !!this.isCat, isBot: !!this.isBot, isLocked: !!this.isLocked, @@ -193,12 +226,27 @@ export default Vue.extend({ this.$store.state.i.bannerUrl = i.bannerUrl; if (notify) { - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('saved') }); } }); + }, + + updateEmail() { + this.$root.dialog({ + title: this.$t('@.enter-password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/update_email', { + password: password, + email: this.email == '' ? null : this.email + }); + }); } } }); diff --git a/src/client/app/common/views/components/renote.vue b/src/client/app/common/views/components/renote.vue new file mode 100644 index 0000000000..eae7bd122d --- /dev/null +++ b/src/client/app/common/views/components/renote.vue @@ -0,0 +1,110 @@ +<template> +<div class="puqkfets" :class="{ mini }"> + <mk-avatar class="avatar" :user="note.user"/> + <fa icon="retweet"/> + <i18n path="@.renoted-by" tag="span"> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.userId" place="user"> + <mk-user-name :user="note.user"/> + </router-link> + </i18n> + <div class="info"> + <span class="mobile" v-if="note.viaMobile"><fa icon="mobile-alt"/></span> + <mk-time :time="note.createdAt"/> + <span class="visibility" v-if="note.visibility != 'public'"> + <fa v-if="note.visibility == 'home'" icon="home"/> + <fa v-if="note.visibility == 'followers'" icon="unlock"/> + <fa v-if="note.visibility == 'specified'" icon="envelope"/> + <fa v-if="note.visibility == 'private'" icon="lock"/> + </span> + <span class="localOnly" v-if="note.localOnly == true"><fa icon="heart"/></span> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import i18n from '../../../i18n'; + +export default Vue.extend({ + i18n: i18n(), + props: { + note: { + type: Object, + required: true + }, + mini: { + type: Boolean, + required: false, + default: false + } + } +}); +</script> + +<style lang="stylus" scoped> +.puqkfets + display flex + align-items center + padding 16px 32px 8px 32px + line-height 28px + white-space pre + color var(--renoteText) + background linear-gradient(to bottom, var(--renoteGradient) 0%, var(--face) 100%) + + &.mini + padding 8px 16px + + @media (min-width 500px) + padding 16px + + @media (min-width 600px) + padding 16px 32px + + > .avatar + @media (min-width 500px) + width 28px + height 28px + + > .avatar + flex-shrink 0 + display inline-block + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + > [data-icon] + 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 + + > .mobile + margin-right 8px + + > .mk-time + flex-shrink 0 + + > .visibility + margin-left 8px + + [data-icon] + margin-right 0 + + > .localOnly + margin-left 4px + + [data-icon] + margin-right 0 + +</style> diff --git a/src/client/app/common/views/components/signin.vue b/src/client/app/common/views/components/signin.vue index c1a7522b00..dd3d979852 100644 --- a/src/client/app/common/views/components/signin.vue +++ b/src/client/app/common/views/components/signin.vue @@ -67,7 +67,8 @@ export default Vue.extend({ username: this.username, password: this.password, token: this.user && this.user.twoFactorEnabled ? this.token : undefined - }, true).then(() => { + }, true).then(res => { + localStorage.setItem('i', res.i); location.reload(); }).catch(() => { alert(this.$t('login-failed')); diff --git a/src/client/app/common/views/components/theme.vue b/src/client/app/common/views/components/theme.vue index 8e23d4cfa7..6a90c30214 100644 --- a/src/client/app/common/views/components/theme.vue +++ b/src/client/app/common/views/components/theme.vue @@ -223,7 +223,7 @@ export default Vue.extend({ try { theme = JSON5.parse(code); } catch (e) { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: this.$t('invalid-theme') }); @@ -236,7 +236,7 @@ export default Vue.extend({ } if (theme.id == null) { - this.$root.alert({ + this.$root.dialog({ type: 'error', text: this.$t('invalid-theme') }); @@ -244,7 +244,7 @@ export default Vue.extend({ } if (this.$store.state.device.themes.some(t => t.id == theme.id)) { - this.$root.alert({ + this.$root.dialog({ type: 'info', text: this.$t('already-installed') }); @@ -256,7 +256,7 @@ export default Vue.extend({ key: 'themes', value: themes }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('installed').replace('{}', theme.name) }); @@ -269,7 +269,7 @@ export default Vue.extend({ key: 'themes', value: themes }); - this.$root.alert({ + this.$root.dialog({ type: 'info', text: this.$t('uninstalled').replace('{}', theme.name) }); @@ -306,7 +306,7 @@ export default Vue.extend({ const theme = this.myTheme; if (theme.name == null || theme.name.trim() == '') { - this.$root.alert({ + this.$root.dialog({ type: 'warning', text: this.$t('theme-name-required') }); @@ -320,7 +320,7 @@ export default Vue.extend({ key: 'themes', value: themes }); - this.$root.alert({ + this.$root.dialog({ type: 'success', text: this.$t('saved') }); diff --git a/src/client/app/common/views/components/twitter-setting.vue b/src/client/app/common/views/components/twitter-setting.vue deleted file mode 100644 index f75bbb7fbf..0000000000 --- a/src/client/app/common/views/components/twitter-setting.vue +++ /dev/null @@ -1,65 +0,0 @@ -<template> -<div class="mk-twitter-setting"> - <p>{{ $t('description') }}</p> - <p class="account" v-if="$store.state.i.twitter" :title="`Twitter ID: ${$store.state.i.twitter.userId}`">{{ $t('connected-to') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> - <p> - <a :href="`${apiUrl}/connect/twitter`" target="_blank" @click.prevent="connect">{{ $store.state.i.twitter ? this.$t('reconnect') : this.$t('connect') }}</a> - <span v-if="$store.state.i.twitter"> or </span> - <a :href="`${apiUrl}/disconnect/twitter`" target="_blank" v-if="$store.state.i.twitter" @click.prevent="disconnect">{{ $t('disconnect') }}</a> - </p> - <p class="id" v-if="$store.state.i.twitter">Twitter ID: {{ $store.state.i.twitter.userId }}</p> -</div> -</template> - -<script lang="ts"> -import Vue from 'vue'; -import i18n from '../../../i18n'; -import { apiUrl } from '../../../config'; - -export default Vue.extend({ - i18n: i18n('common/views/components/twitter-setting.vue'), - data() { - return { - form: null, - apiUrl - }; - }, - mounted() { - this.$watch('$store.state.i', () => { - if (this.$store.state.i.twitter) { - if (this.form) this.form.close(); - } - }, { - deep: true - }); - }, - methods: { - connect() { - this.form = window.open(apiUrl + '/connect/twitter', - 'twitter_connect_window', - 'height=570, width=520'); - }, - - disconnect() { - window.open(apiUrl + '/disconnect/twitter', - 'twitter_disconnect_window', - 'height=570, width=520'); - } - } -}); -</script> - -<style lang="stylus" scoped> -.mk-twitter-setting - .account - border solid 1px #e1e8ed - border-radius 4px - padding 16px - - a - font-weight bold - color inherit - - .id - color #8899a6 -</style> diff --git a/src/client/app/common/views/components/ui/button.vue b/src/client/app/common/views/components/ui/button.vue index d7d65ad87e..42bdc31713 100644 --- a/src/client/app/common/views/components/ui/button.vue +++ b/src/client/app/common/views/components/ui/button.vue @@ -4,8 +4,12 @@ :class="[styl, { inline, primary }]" :type="type" @click="$emit('click')" + @mousedown="onMousedown" > - <slot></slot> + <div ref="ripples" class="ripples"></div> + <div class="content"> + <slot></slot> + </div> </component> </template> @@ -56,6 +60,47 @@ export default Vue.extend({ this.$el.focus(); }); } + }, + methods: { + onMousedown(e: MouseEvent) { + function distance(p, q) { + const sqrt = Math.sqrt, pow = Math.pow; + return sqrt(pow(p.x - q.x, 2) + pow(p.y - q.y, 2)); + } + + 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> @@ -79,6 +124,10 @@ export default Vue.extend({ * pointer-events none + user-select none + + &:disabled + opacity 0.7 &:focus &:after @@ -107,30 +156,56 @@ export default Vue.extend({ color var(--text) background var(--buttonBg) - &:hover + &:not(:disabled):hover background var(--buttonHoverBg) - &:active + &:not(:disabled):active background var(--buttonActiveBg) &.primary color var(--primaryForeground) background var(--primary) - &:hover + &:not(:disabled):hover background var(--primaryLighten5) - &:active + &:not(:disabled):active background var(--primaryDarken5) &:not(.fill) color var(--primary) background none - &:hover + &:not(:disabled):hover color var(--primaryDarken5) - &:active + &:not(:disabled):active background var(--primaryAlpha03) + > .ripples + position absolute + z-index 0 + top 0 + left 0 + width 100% + height 100% + border-radius 6px + overflow hidden + + >>> 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, .5, 1) + + &.primary > .ripples >>> div + background rgba(0, 0, 0, 0.15) + + > .content + z-index 1 + </style> diff --git a/src/client/app/common/views/components/ui/card.vue b/src/client/app/common/views/components/ui/card.vue index dbbf7b14a0..21ccf95aaf 100644 --- a/src/client/app/common/views/components/ui/card.vue +++ b/src/client/app/common/views/components/ui/card.vue @@ -22,6 +22,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .ui-card margin 16px + max-width 850px color var(--faceText) background var(--face) border-radius var(--round) diff --git a/src/client/app/common/views/components/ui/horizon-group.vue b/src/client/app/common/views/components/ui/horizon-group.vue index 0d4eafae52..339ab790a0 100644 --- a/src/client/app/common/views/components/ui/horizon-group.vue +++ b/src/client/app/common/views/components/ui/horizon-group.vue @@ -27,15 +27,25 @@ export default Vue.extend({ <style lang="stylus" scoped> .vnxwkwuf + margin 16px 0 + &.inputs margin 32px 0 + &.fit-top + margin-top 0 + + &.fit-bottom + margin-bottom 0 + &:not(.noGrow) display flex > * flex 1 + min-width 0 !important > *:not(:last-child) - margin-right 16px + margin-right 16px !important + </style> diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue index 76bb34da61..d735cc1c2f 100644 --- a/src/client/app/common/views/components/ui/input.vue +++ b/src/client/app/common/views/components/ui/input.vue @@ -9,27 +9,32 @@ <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> <template v-if="type != 'file'"> <input ref="input" - :type="type" - v-model="v" - :disabled="disabled" - :required="required" - :readonly="readonly" - :pattern="pattern" - :autocomplete="autocomplete" - :spellcheck="spellcheck" - @focus="focused = true" - @blur="focused = false"> + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + @focus="focused = true" + @blur="focused = false" + @keydown="$emit('keydown', $event)" + > </template> <template v-else> <input ref="input" - type="text" - :value="placeholder" - readonly - @click="chooseFile"> + type="text" + :value="filePlaceholder" + readonly + @click="chooseFile" + > <input ref="file" - type="file" - :value="value" - @change="onChangeFile"> + type="file" + :value="value" + @change="onChangeFile" + > </template> <div class="suffix" ref="suffix"><slot name="suffix"></slot></div> </div> @@ -71,6 +76,15 @@ export default Vue.extend({ type: String, required: false }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, autocomplete: { required: false }, @@ -106,7 +120,7 @@ export default Vue.extend({ filled(): boolean { return this.v != '' && this.v != null; }, - placeholder(): string { + filePlaceholder(): string { if (this.type != 'file') return null; if (this.v == null) return null; @@ -139,6 +153,12 @@ export default Vue.extend({ } }, mounted() { + if (this.autofocus) { + this.$nextTick(() => { + this.$refs.input.focus(); + }); + } + this.$nextTick(() => { if (this.$refs.prefix) { this.$refs.label.style.left = (this.$refs.prefix.offsetLeft + this.$refs.prefix.offsetWidth) + 'px'; @@ -325,6 +345,9 @@ root(fill) margin 6px 0 font-size 13px + &:empty + display none + * margin 0 diff --git a/src/client/app/common/views/components/ui/select.vue b/src/client/app/common/views/components/ui/select.vue index da6f9696b5..e8b45a4a29 100644 --- a/src/client/app/common/views/components/ui/select.vue +++ b/src/client/app/common/views/components/ui/select.vue @@ -1,15 +1,17 @@ <template> -<div class="ui-select" :class="[{ focused, filled }, styl]"> +<div class="ui-select" :class="[{ focused, disabled, filled, inline }, styl]"> <div class="icon" ref="icon"><slot name="icon"></slot></div> <div class="input" @click="focus"> <span class="label" ref="label"><slot name="label"></slot></span> <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> <select ref="input" - :value="v" - :required="required" - @input="$emit('input', $event.target.value)" - @focus="focused = true" - @blur="focused = false"> + :value="v" + :required="required" + :disabled="disabled" + @input="$emit('input', $event.target.value)" + @focus="focused = true" + @blur="focused = false" + > <slot></slot> </select> <div class="suffix"><slot name="suffix"></slot></div> @@ -22,6 +24,11 @@ import Vue from 'vue'; export default Vue.extend({ + inject: { + horizonGrouped: { + default: false + } + }, props: { value: { required: false @@ -30,11 +37,22 @@ export default Vue.extend({ type: Boolean, required: false }, + disabled: { + type: Boolean, + required: false + }, styl: { type: String, required: false, default: 'line' - } + }, + inline: { + type: Boolean, + required: false, + default(): boolean { + return this.horizonGrouped; + } + }, }, data() { return { @@ -76,7 +94,7 @@ root(fill) width 24px text-align center line-height 32px - color rgba(#000, 0.54) + color var(--inputLabel) &:not(:empty) + .input margin-left 28px @@ -122,7 +140,7 @@ root(fill) transition-duration 0.3s font-size 16px line-height 32px - color rgba(#000, 0.54) + color var(--inputLabel) pointer-events none //will-change transform transform-origin top left @@ -171,6 +189,9 @@ root(fill) margin 6px 0 font-size 13px + &:empty + display none + * margin 0 @@ -200,4 +221,14 @@ root(fill) &:not(.fill) root(false) + &.inline + display inline-block + margin 0 + + &.disabled + opacity 0.7 + + &, * + cursor not-allowed !important + </style> diff --git a/src/client/app/common/views/components/ui/switch.vue b/src/client/app/common/views/components/ui/switch.vue index c9a9cb7911..b8bd9e2fcd 100644 --- a/src/client/app/common/views/components/ui/switch.vue +++ b/src/client/app/common/views/components/ui/switch.vue @@ -123,7 +123,7 @@ export default Vue.extend({ > span display block line-height 20px - color currentColor + color var(--text) transition inherit > p diff --git a/src/client/app/common/views/components/ui/textarea.vue b/src/client/app/common/views/components/ui/textarea.vue index 8ebc79e097..d265c7ac6d 100644 --- a/src/client/app/common/views/components/ui/textarea.vue +++ b/src/client/app/common/views/components/ui/textarea.vue @@ -1,5 +1,5 @@ <template> -<div class="ui-textarea" :class="{ focused, filled, tall }"> +<div class="ui-textarea" :class="{ focused, filled, tall, pre }"> <div class="input"> <span class="label" ref="label"><slot></slot></span> <textarea ref="input" @@ -46,6 +46,11 @@ export default Vue.extend({ required: false, default: false }, + pre: { + type: Boolean, + required: false, + default: false + }, }, data() { return { @@ -126,6 +131,8 @@ root(fill) > textarea display block width 100% + min-width 100% + max-width 100% min-height 100px padding 0 font inherit @@ -143,6 +150,9 @@ root(fill) font-size 13px opacity 0.7 + &:empty + display none + * margin 0 @@ -170,6 +180,11 @@ root(fill) > textarea min-height 200px + &.pre + > .input + > textarea + white-space pre + .ui-textarea.fill root(true) diff --git a/src/client/app/common/views/components/user-name.vue b/src/client/app/common/views/components/user-name.vue new file mode 100644 index 0000000000..7719357e38 --- /dev/null +++ b/src/client/app/common/views/components/user-name.vue @@ -0,0 +1,16 @@ +<template> +<misskey-flavored-markdown :text="user.name || user.username" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + } + } +}); +</script> diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue index cad09a11a6..84575b35d6 100644 --- a/src/client/app/common/views/components/welcome-timeline.vue +++ b/src/client/app/common/views/components/welcome-timeline.vue @@ -5,7 +5,9 @@ <mk-avatar class="avatar" :user="note.user" target="_blank"/> <div class="body"> <header> - <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id">{{ note.user | userName }}</router-link> + <router-link class="name" :to="note.user | userPage" v-user-preview="note.user.id"> + <mk-user-name :user="note.user"/> + </router-link> <span class="username">@{{ note.user | acct }}</span> <div class="info"> <router-link class="created-at" :to="note | notePage"> @@ -14,7 +16,7 @@ </div> </header> <div class="text"> - <misskey-flavored-markdown v-if="note.text" :text="note.text" :customEmojis="note.emojis"/> + <misskey-flavored-markdown v-if="note.text" :text="note.text" :author="note.user" :custom-emojis="note.emojis"/> </div> </div> </div> diff --git a/src/client/app/common/views/filters/index.ts b/src/client/app/common/views/filters/index.ts index 1759c19c2c..3dccbfc923 100644 --- a/src/client/app/common/views/filters/index.ts +++ b/src/client/app/common/views/filters/index.ts @@ -1,3 +1,10 @@ +import Vue from 'vue'; +import * as JSON5 from 'json5'; + +Vue.filter('json5', x => { + return JSON5.stringify(x, null, 2); +}); + require('./bytes'); require('./number'); require('./user'); diff --git a/src/client/app/common/views/filters/number.ts b/src/client/app/common/views/filters/number.ts index 08f9fea805..8c799d9442 100644 --- a/src/client/app/common/views/filters/number.ts +++ b/src/client/app/common/views/filters/number.ts @@ -1,6 +1,3 @@ import Vue from 'vue'; -Vue.filter('number', (n) => { - if (n == null) return 'N/A'; - return n.toLocaleString(); -}); +Vue.filter('number', n => n == null ? 'N/A' : n.toLocaleString()); diff --git a/src/client/app/common/views/filters/user.ts b/src/client/app/common/views/filters/user.ts index e5220229b7..9d4ae5c58b 100644 --- a/src/client/app/common/views/filters/user.ts +++ b/src/client/app/common/views/filters/user.ts @@ -1,6 +1,7 @@ import Vue from 'vue'; import getAcct from '../../../../../misc/acct/render'; import getUserName from '../../../../../misc/get-user-name'; +import { url } from '../../../config'; Vue.filter('acct', user => { return getAcct(user); @@ -10,6 +11,6 @@ Vue.filter('userName', user => { return getUserName(user); }); -Vue.filter('userPage', (user, path?) => { - return `/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`; +Vue.filter('userPage', (user, path?, absolute = false) => { + return `${absolute ? url : ''}/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`; }); diff --git a/src/client/app/common/views/pages/404.vue b/src/client/app/common/views/pages/404.vue new file mode 100644 index 0000000000..5d6db50758 --- /dev/null +++ b/src/client/app/common/views/pages/404.vue @@ -0,0 +1,65 @@ +<template> +<figure class="megtcxgu"> + <img :src="src" alt=""> + <figcaption> + <h1><span>Not found</span></h1> + <p><span>{{ $t('page-not-found') }}</span></p> + </figcaption> +</figure> +</template> + +<script lang="ts"> +import Vue from 'vue' +import i18n from '../../../i18n'; + +export default Vue.extend({ + i18n: i18n('common/views/pages/404.vue'), + data() { + return { + src: '' + } + }, + created() { + this.$root.getMeta().then(meta => { + if (meta.errorImageUrl) + this.src = meta.errorImageUrl; + }); + } +}) +</script> + +<style lang="stylus" scoped> +.megtcxgu + align-items center + bottom 0 + display flex + justify-content center + left 0 + margin auto + position fixed + right 0 + top 0 + + > img + width 500px + + > figcaption + margin 8px + + h1, + p + color var(--text) + display flex + flex-flow column + + * + position relative + width 100% + + @media (max-width: 767px) + flex-flow column + + > figcaption + text-align center + +</style> diff --git a/src/client/app/common/views/pages/follow.vue b/src/client/app/common/views/pages/follow.vue index 9db53fdf8a..854982d91a 100644 --- a/src/client/app/common/views/pages/follow.vue +++ b/src/client/app/common/views/pages/follow.vue @@ -6,10 +6,12 @@ <div class="banner" :style="bannerStyle"></div> <mk-avatar class="avatar" :user="user" :disable-preview="true"/> <div class="body"> - <router-link :to="user | userPage" class="name">{{ user | userName }}</router-link> + <router-link :to="user | userPage" class="name"> + <mk-user-name :user="user"/> + </router-link> <span class="username">@{{ user | acct }}</span> <div class="description"> - <misskey-flavored-markdown v-if="user.description" :text="user.description" :i="$store.state.i"/> + <misskey-flavored-markdown v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> </div> </div> </main> diff --git a/src/client/app/common/views/widgets/donation.vue b/src/client/app/common/views/widgets/donation.vue deleted file mode 100644 index 057813891c..0000000000 --- a/src/client/app/common/views/widgets/donation.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<div> - <mk-widget-container :show-header="false"> - <article class="dolfvtibguprpxxhfndqaosjitixjohx"> - <h1><fa icon="heart"/>{{ $t('title') }}</h1> - <p v-if="meta"> - {{ this.$t('text').substr(0, this.$t('text').indexOf('{')) }} - <a :href="'mailto:' + meta.maintainer.email">{{ meta.maintainer.name }}</a> - {{ this.$t('text').substr(this.$t('text').indexOf('}') + 1) }} - </p> - </article> - </mk-widget-container> -</div> -</template> - -<script lang="ts"> -import define from '../../../common/define-widget'; -import i18n from '../../../i18n'; - -export default define({ - name: 'donation' -}).extend({ - i18n: i18n('common/views/widgets/donation.vue'), - data() { - return { - meta: null - }; - }, - created() { - this.$root.getMeta().then(meta => { - this.meta = meta; - }); - } -}); -</script> - -<style lang="stylus" scoped> -.dolfvtibguprpxxhfndqaosjitixjohx - padding 20px - background var(--donationBg) - color var(--donationFg) - - > h1 - margin 0 0 5px 0 - font-size 1em - - > [data-icon] - margin-right 0.25em - - > p - display block - z-index 1 - margin 0 - font-size 0.8em - -</style> diff --git a/src/client/app/common/views/widgets/index.ts b/src/client/app/common/views/widgets/index.ts index 7d548ef353..7fca79f1fc 100644 --- a/src/client/app/common/views/widgets/index.ts +++ b/src/client/app/common/views/widgets/index.ts @@ -11,7 +11,6 @@ import wCalendar from './calendar.vue'; import wPhotoStream from './photo-stream.vue'; import wSlideshow from './slideshow.vue'; import wTips from './tips.vue'; -import wDonation from './donation.vue'; import wNav from './nav.vue'; import wHashtags from './hashtags.vue'; @@ -21,7 +20,6 @@ Vue.component('mkw-calendar', wCalendar); Vue.component('mkw-photo-stream', wPhotoStream); Vue.component('mkw-slideshow', wSlideshow); Vue.component('mkw-tips', wTips); -Vue.component('mkw-donation', wDonation); Vue.component('mkw-broadcast', wBroadcast); Vue.component('mkw-server', wServer); Vue.component('mkw-posts-monitor', wPostsMonitor); diff --git a/src/client/app/common/views/widgets/photo-stream.vue b/src/client/app/common/views/widgets/photo-stream.vue index 13bae64bd0..516c626323 100644 --- a/src/client/app/common/views/widgets/photo-stream.vue +++ b/src/client/app/common/views/widgets/photo-stream.vue @@ -10,7 +10,6 @@ :style="`background-image: url(${image.thumbnailUrl || image.url})`" draggable="true" @dragstart="onDragstart(image, $event)" - @dragend="onDragend" ></div> </div> <p :class="$style.empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p> @@ -78,10 +77,6 @@ export default define({ e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('mk_drive_file', JSON.stringify(file)); }, - - onDragend(e) { - this.browser.isDragSource = false; - }, } }); </script> diff --git a/src/client/app/common/views/widgets/posts-monitor.vue b/src/client/app/common/views/widgets/posts-monitor.vue index 9b2cc5a6cd..1af306b881 100644 --- a/src/client/app/common/views/widgets/posts-monitor.vue +++ b/src/client/app/common/views/widgets/posts-monitor.vue @@ -164,7 +164,7 @@ export default define({ this.draw(); }, onStatsLog(statsLog) { - statsLog.forEach(stats => this.onStats(stats)); + for (const stats of statsLog) this.onStats(stats); } } }); diff --git a/src/client/app/common/views/widgets/server.cpu-memory.vue b/src/client/app/common/views/widgets/server.cpu-memory.vue index 4a0341ddcd..92e5479b1b 100644 --- a/src/client/app/common/views/widgets/server.cpu-memory.vue +++ b/src/client/app/common/views/widgets/server.cpu-memory.vue @@ -121,7 +121,7 @@ export default Vue.extend({ this.memP = (stats.mem.used / stats.mem.total * 100).toFixed(0); }, onStatsLog(statsLog) { - statsLog.reverse().forEach(stats => this.onStats(stats)); + for (const stats of statsLog.reverse()) this.onStats(stats); } } }); diff --git a/src/client/app/common/views/widgets/server.cpu.vue b/src/client/app/common/views/widgets/server.cpu.vue index 986577c51f..c08971e11c 100644 --- a/src/client/app/common/views/widgets/server.cpu.vue +++ b/src/client/app/common/views/widgets/server.cpu.vue @@ -3,7 +3,7 @@ <x-pie class="pie" :value="usage"/> <div> <p><fa icon="microchip"/>CPU</p> - <p>{{ meta.cpu.cores }} Cores</p> + <p>{{ meta.cpu.cores }} Logical cores</p> <p>{{ meta.cpu.model }}</p> </div> </div> |